@@ -55,6 +55,12 @@ class JavaModulePlugin implements Plugin<Project> {
|
||||
failOnError false
|
||||
}
|
||||
|
||||
project.sonarqube {
|
||||
properties {
|
||||
property "sonar.java.source", "8"
|
||||
}
|
||||
}
|
||||
|
||||
project.afterEvaluate {
|
||||
if (project.isCI) {
|
||||
project.plugins.apply("jacoco")
|
||||
|
||||
@@ -5,3 +5,4 @@
|
||||
- /user/group/
|
||||
- /user/admin/
|
||||
- /user/profile/
|
||||
- /user/notification/
|
||||
|
||||
BIN
docs/de/user/notification/assets/bell.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
docs/de/user/notification/assets/emptybell.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
docs/de/user/notification/assets/notifications.png
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
docs/de/user/notification/assets/toast.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
36
docs/de/user/notification/index.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: Benachrichtigungen
|
||||
partiallyActive: true
|
||||
---
|
||||
|
||||
Benachrichtigungen werden in SCM-Manager verwendet, um die Fertigstellung von langlaufenden Prozessen anzuzeigen
|
||||
oder um den Benutzer auf Fehler hinzuweisen.
|
||||
|
||||
Aktuelle Benachrichtigungen tauchen in Form einer Toast-Benachrichtigung am unteren rechten Rand auf.
|
||||
|
||||

|
||||
|
||||
Die Farbe der Benachrichtigung gibt den Typ der Nachricht an:
|
||||
|
||||
* Fehler (Rot): Ein Fehler ist aufgetreten
|
||||
* Warnung (Gelb): Es ist ein Problem aufgetreten
|
||||
* Erfolgreich (Grün): Eine Aktion wurde erfolgreich beendet
|
||||
* Information (Blau): Zur Information
|
||||
|
||||
Die Nachrichten verweisen auf eine Unterseite des SCM-Managers, die sich mit einem Klick auf den Text aufrufen lässt.
|
||||
Nach dem Lesen der Nachricht, kann sie mit einem Klick auf das X in der oberen rechten Ecke geschlossen werden.
|
||||
Nach dem Schließen der Nachricht ist sie immer noch über die Glocke am oberen rechten Rand zu finden.
|
||||
|
||||

|
||||
|
||||
Die Zahl neben der Glock gibt an wie viele Nachrichten eingegangen sind.
|
||||
Mit einem Klick öffnet sich die Liste der Nachrichten. Neben den Informationen wie Typ der Nachricht (farbiger Rand) und Eingangsdatum, kann über einen Klick auf den Text zu einer SCM-Manager Unterseite navigiert werden.
|
||||
|
||||

|
||||
|
||||
Über das Mülleimer-Symbol lässt sich eine einzelne Nachricht löschen.
|
||||
Alle Nachrichten lassen sich über den "Alle löschen" Button löschen.
|
||||
|
||||
Wenn keine Nachrichten mehr vorhanden sind, zeigt das Glocken-Symbol keinen Zähler mehr an.
|
||||
|
||||

|
||||
@@ -13,6 +13,7 @@
|
||||
- /user/group/
|
||||
- /user/admin/
|
||||
- /user/profile/
|
||||
- /user/notification/
|
||||
|
||||
- section: Administration
|
||||
entries:
|
||||
|
||||
BIN
docs/en/user/notification/assets/bell.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
docs/en/user/notification/assets/emptybell.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
docs/en/user/notification/assets/notifications.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
docs/en/user/notification/assets/toast.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
38
docs/en/user/notification/index.md
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
title: Notifications
|
||||
partiallyActive: true
|
||||
---
|
||||
|
||||
Notifications are used in SCM Manager to indicate the completion of long-running processes
|
||||
or to alert the user about errors and warnings.
|
||||
|
||||
Current notifications appear in the form of a toast notification at the bottom right.
|
||||
|
||||

|
||||
|
||||
The color of the notification indicates the type of message:
|
||||
|
||||
* Error (red): An error has occurred.
|
||||
* Warning (yellow): A problem has occurred
|
||||
* Successful (green): An action has been successfully completed
|
||||
* Information (blue): For informational notifications
|
||||
|
||||
The messages refer to a page of SCM Manager, which can be accessed by clicking on the text of the notification.
|
||||
When you have read the message, you can close it by clicking on the X in the upper right corner.
|
||||
The messages can also be reviewed by clicking on the bell icon in the upper right corner.
|
||||
|
||||

|
||||
|
||||
The number next to the bell icon indicates how many messages have been received.
|
||||
If you move the mouse over the icon, you can read the messages.
|
||||
The colored border indicates the type of the message, the date indicates when the message was received
|
||||
and by clicking on the text you can open the page of the message.
|
||||
|
||||

|
||||
|
||||
The trash icon can be used to delete a single message.
|
||||
All messages can be deleted by clicking the "Dismiss all" button.
|
||||
|
||||
If there are no messages left, the bell icon will not show a counter anymore.
|
||||
|
||||

|
||||
2
gradle/changelog/global_notifications.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
- type: added
|
||||
description: Add global notifications ([#1646](https://github.com/scm-manager/scm-manager/pull/1646))
|
||||
@@ -100,6 +100,7 @@ ext {
|
||||
|
||||
ssp: "com.github.sdorra:ssp-lib:${sspVersion}",
|
||||
sspProcessor: "com.github.sdorra:ssp-processor:${sspVersion}",
|
||||
|
||||
shiroUnit: 'com.github.sdorra:shiro-unit:1.0.1',
|
||||
shiroExtension: 'com.github.sdorra:junit-shiro-extension:1.0.1',
|
||||
|
||||
|
||||
@@ -109,6 +109,9 @@ dependencies {
|
||||
testImplementation libraries.junitJupiterParams
|
||||
testRuntimeOnly libraries.junitJupiterEngine
|
||||
|
||||
// shiro
|
||||
testImplementation libraries.shiroExtension
|
||||
|
||||
// junit 4 support
|
||||
testRuntimeOnly libraries.junitVintageEngine
|
||||
testImplementation libraries.junit
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package sonia.scm.notifications;
|
||||
|
||||
import lombok.Value;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Notifications can be used to send a message to specific user.
|
||||
*
|
||||
* @since 2.18.0
|
||||
*/
|
||||
@Value
|
||||
public class Notification {
|
||||
|
||||
Type type;
|
||||
String link;
|
||||
String message;
|
||||
Instant createdAt;
|
||||
|
||||
public Notification(Type type, String link, String message) {
|
||||
this(type, link, message, Instant.now());
|
||||
}
|
||||
|
||||
Notification(Type type, String link, String message, Instant createdAt) {
|
||||
this.type = type;
|
||||
this.link = link;
|
||||
this.message = message;
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package sonia.scm.notifications;
|
||||
|
||||
import org.apache.shiro.SecurityUtils;
|
||||
|
||||
/**
|
||||
* Service for sending notifications.
|
||||
*
|
||||
* @since 2.18.0
|
||||
*/
|
||||
public interface NotificationSender {
|
||||
|
||||
/**
|
||||
* Sends the notification to a specific user.
|
||||
* @param notification notification to send
|
||||
* @param recipient username of the receiving user
|
||||
*/
|
||||
void send(Notification notification, String recipient);
|
||||
|
||||
/**
|
||||
* Sends the notification to the current user.
|
||||
* @param notification notification to send
|
||||
*/
|
||||
default void send(Notification notification) {
|
||||
send(notification, SecurityUtils.getSubject().getPrincipal().toString());
|
||||
}
|
||||
}
|
||||
51
scm-core/src/main/java/sonia/scm/notifications/Type.java
Normal file
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package sonia.scm.notifications;
|
||||
|
||||
/**
|
||||
* Type of notification.
|
||||
* @since 2.18.0
|
||||
*/
|
||||
public enum Type {
|
||||
/**
|
||||
* Notifications with an informative character e.g.: update available
|
||||
*/
|
||||
INFO,
|
||||
|
||||
/**
|
||||
* Success should be used if a long running action is finished successfully e.g.: export is ready to download
|
||||
*/
|
||||
SUCCESS,
|
||||
|
||||
/**
|
||||
* Notifications with a warning character e.g.: disk space is filled up to 80 percent.
|
||||
*/
|
||||
WARNING,
|
||||
|
||||
/**
|
||||
* Error should be used in the case of an failure e.g.: export failed
|
||||
*/
|
||||
ERROR
|
||||
}
|
||||
@@ -21,10 +21,9 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
|
||||
package sonia.scm.security;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import com.google.common.base.Preconditions;
|
||||
import com.google.common.base.Strings;
|
||||
import lombok.EqualsAndHashCode;
|
||||
@@ -40,7 +39,6 @@ import java.util.Optional;
|
||||
@EqualsAndHashCode
|
||||
public final class SessionId implements Serializable {
|
||||
|
||||
@VisibleForTesting
|
||||
public static final String PARAMETER = "X-SCM-Session-ID";
|
||||
|
||||
private final String value;
|
||||
|
||||
120
scm-core/src/main/java/sonia/scm/sse/Channel.java
Normal file
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package sonia.scm.sse;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import sonia.scm.security.SessionId;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.locks.ReadWriteLock;
|
||||
import java.util.concurrent.locks.ReentrantReadWriteLock;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
public class Channel {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(Channel.class);
|
||||
|
||||
private final List<Client> clients = new ArrayList<>();
|
||||
private final ReadWriteLock lock = new ReentrantReadWriteLock();
|
||||
|
||||
private final Object channelId;
|
||||
private final Function<Registration, Client> clientFactory;
|
||||
|
||||
Channel(Object channelId) {
|
||||
this(channelId, Client::new);
|
||||
}
|
||||
|
||||
Channel(Object channelId, Function<Registration, Client> clientFactory) {
|
||||
this.channelId = channelId;
|
||||
this.clientFactory = clientFactory;
|
||||
}
|
||||
|
||||
public void register(Registration registration) {
|
||||
registration.validate();
|
||||
Client client = clientFactory.apply(registration);
|
||||
|
||||
LOG.trace("registered new client {} to channel {}", client.getSessionId(), channelId);
|
||||
lock.writeLock().lock();
|
||||
try {
|
||||
clients.add(client);
|
||||
} finally {
|
||||
lock.writeLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public void broadcast(Message message) {
|
||||
LOG.trace("broadcast message {} to clients of channel {}", message, channelId);
|
||||
lock.readLock().lock();
|
||||
try {
|
||||
clients.stream()
|
||||
.filter(isNotSender(message))
|
||||
.forEach(client -> client.send(message));
|
||||
} finally {
|
||||
lock.readLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private Predicate<? super Client> isNotSender(Message message) {
|
||||
return client -> {
|
||||
Optional<SessionId> senderSessionId = message.getSender();
|
||||
return senderSessionId
|
||||
.map(sessionId -> !sessionId.equals(client.getSessionId()))
|
||||
.orElse(true);
|
||||
};
|
||||
}
|
||||
|
||||
void removeClosedOrTimeoutClients() {
|
||||
Instant timeoutLimit = Instant.now().minus(30, ChronoUnit.SECONDS);
|
||||
lock.writeLock().lock();
|
||||
try {
|
||||
int removeCounter = 0;
|
||||
Iterator<Client> it = clients.iterator();
|
||||
while (it.hasNext()) {
|
||||
Client client = it.next();
|
||||
if (client.isClosed()) {
|
||||
LOG.trace("remove closed client with session {}", client.getSessionId());
|
||||
it.remove();
|
||||
removeCounter++;
|
||||
} else if (client.getLastUsed().isBefore(timeoutLimit)) {
|
||||
client.close();
|
||||
LOG.trace("remove client with session {}, because it has reached the timeout", client.getSessionId());
|
||||
it.remove();
|
||||
removeCounter++;
|
||||
}
|
||||
}
|
||||
LOG.trace("removed {} closed clients from channel", removeCounter);
|
||||
} finally {
|
||||
lock.writeLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
43
scm-core/src/main/java/sonia/scm/sse/ChannelCleanupTask.java
Normal file
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package sonia.scm.sse;
|
||||
|
||||
import javax.inject.Inject;
|
||||
|
||||
public class ChannelCleanupTask implements Runnable {
|
||||
|
||||
private final ChannelRegistry registry;
|
||||
|
||||
@Inject
|
||||
public ChannelCleanupTask(ChannelRegistry registry) {
|
||||
this.registry = registry;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
registry.removeClosedClients();
|
||||
}
|
||||
|
||||
}
|
||||
56
scm-core/src/main/java/sonia/scm/sse/ChannelRegistry.java
Normal file
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package sonia.scm.sse;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
|
||||
import javax.inject.Singleton;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Function;
|
||||
|
||||
@Singleton
|
||||
public class ChannelRegistry {
|
||||
|
||||
private final Map<Object, Channel> channels = new ConcurrentHashMap<>();
|
||||
private final Function<Object, Channel> channelFactory;
|
||||
|
||||
public ChannelRegistry() {
|
||||
this(Channel::new);
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
ChannelRegistry(Function<Object, Channel> channelFactory) {
|
||||
this.channelFactory = channelFactory;
|
||||
}
|
||||
|
||||
public Channel channel(Object channelId) {
|
||||
return channels.computeIfAbsent(channelId, channelFactory);
|
||||
}
|
||||
|
||||
void removeClosedClients() {
|
||||
channels.values().forEach(Channel::removeClosedOrTimeoutClients);
|
||||
}
|
||||
}
|
||||
100
scm-core/src/main/java/sonia/scm/sse/Client.java
Normal file
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package sonia.scm.sse;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import sonia.scm.security.SessionId;
|
||||
|
||||
import javax.ws.rs.sse.OutboundSseEvent;
|
||||
import javax.ws.rs.sse.SseEventSink;
|
||||
import java.io.Closeable;
|
||||
import java.time.Instant;
|
||||
import java.util.function.Function;
|
||||
|
||||
class Client implements Closeable {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(Client.class);
|
||||
|
||||
private final SessionId sessionId;
|
||||
private final SseEventAdapter adapter;
|
||||
private final SseEventSink eventSink;
|
||||
|
||||
private Instant lastUsed;
|
||||
private boolean exceptionallyClosed = false;
|
||||
|
||||
Client(Registration registration) {
|
||||
this(registration, reg -> new SseEventAdapter(reg.getSse()));
|
||||
}
|
||||
|
||||
Client(Registration registration, Function<Registration, SseEventAdapter> adapterFactory) {
|
||||
sessionId = registration.getSessionId();
|
||||
adapter = adapterFactory.apply(registration);
|
||||
eventSink = registration.getEventSink();
|
||||
lastUsed = Instant.now();
|
||||
}
|
||||
|
||||
Instant getLastUsed() {
|
||||
return lastUsed;
|
||||
}
|
||||
|
||||
SessionId getSessionId() {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
boolean isExceptionallyClosed() {
|
||||
return exceptionallyClosed;
|
||||
}
|
||||
|
||||
void send(Message message) {
|
||||
if (!isClosed()) {
|
||||
OutboundSseEvent event = adapter.create(message);
|
||||
LOG.debug("send message to client with session id {}", sessionId);
|
||||
lastUsed = Instant.now();
|
||||
eventSink.send(event).exceptionally(e -> {
|
||||
if (LOG.isTraceEnabled()) {
|
||||
LOG.trace("failed to send event to client with session id {}:", sessionId, e);
|
||||
} else {
|
||||
LOG.debug("failed to send event to client with session id {}: {}", sessionId, e.getMessage());
|
||||
}
|
||||
exceptionallyClosed = true;
|
||||
close();
|
||||
return null;
|
||||
});
|
||||
} else {
|
||||
LOG.debug("client has closed the connection, before we could send the message");
|
||||
}
|
||||
}
|
||||
|
||||
boolean isClosed() {
|
||||
return exceptionallyClosed || eventSink.isClosed();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
LOG.trace("close sse session of client with session id: {}", sessionId);
|
||||
eventSink.close();
|
||||
}
|
||||
}
|
||||
54
scm-core/src/main/java/sonia/scm/sse/Message.java
Normal file
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package sonia.scm.sse;
|
||||
|
||||
import lombok.Getter;
|
||||
import sonia.scm.security.SessionId;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@Getter
|
||||
public class Message {
|
||||
|
||||
private final String name;
|
||||
private final Class<?> type;
|
||||
private final Object data;
|
||||
private final SessionId sender;
|
||||
|
||||
public Message(String name, Class<?> type, Object data) {
|
||||
this(name, type, data, null);
|
||||
}
|
||||
|
||||
public Message(String name, Class<?> type, Object data, SessionId sender) {
|
||||
this.name = name;
|
||||
this.type = type;
|
||||
this.data = data;
|
||||
this.sender = sender;
|
||||
}
|
||||
|
||||
public Optional<SessionId> getSender() {
|
||||
return Optional.ofNullable(sender);
|
||||
}
|
||||
}
|
||||
46
scm-core/src/main/java/sonia/scm/sse/Registration.java
Normal file
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package sonia.scm.sse;
|
||||
|
||||
import com.google.common.base.Preconditions;
|
||||
import lombok.Value;
|
||||
import sonia.scm.security.SessionId;
|
||||
|
||||
import javax.ws.rs.sse.Sse;
|
||||
import javax.ws.rs.sse.SseEventSink;
|
||||
|
||||
@Value
|
||||
public class Registration {
|
||||
|
||||
SessionId sessionId;
|
||||
Sse sse;
|
||||
SseEventSink eventSink;
|
||||
|
||||
void validate() {
|
||||
Preconditions.checkNotNull(sessionId, "sessionId is required");
|
||||
Preconditions.checkNotNull(sse, "sse is required");
|
||||
Preconditions.checkNotNull(eventSink, "eventSink is required");
|
||||
}
|
||||
}
|
||||
53
scm-core/src/main/java/sonia/scm/sse/SseContextListener.java
Normal file
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package sonia.scm.sse;
|
||||
|
||||
import sonia.scm.plugin.Extension;
|
||||
import sonia.scm.schedule.Scheduler;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import javax.servlet.ServletContextEvent;
|
||||
import javax.servlet.ServletContextListener;
|
||||
|
||||
@Extension
|
||||
public class SseContextListener implements ServletContextListener {
|
||||
|
||||
private final Scheduler scheduler;
|
||||
|
||||
@Inject
|
||||
public SseContextListener(Scheduler scheduler) {
|
||||
this.scheduler = scheduler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void contextInitialized(ServletContextEvent servletContextEvent) {
|
||||
this.scheduler.schedule("0/30 * * * * ?", ChannelCleanupTask.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void contextDestroyed(ServletContextEvent servletContextEvent) {
|
||||
// we have nothing to destroy
|
||||
}
|
||||
}
|
||||
47
scm-core/src/main/java/sonia/scm/sse/SseEventAdapter.java
Normal file
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package sonia.scm.sse;
|
||||
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import javax.ws.rs.sse.OutboundSseEvent;
|
||||
import javax.ws.rs.sse.Sse;
|
||||
|
||||
class SseEventAdapter {
|
||||
|
||||
private final Sse sse;
|
||||
|
||||
SseEventAdapter(Sse sse) {
|
||||
this.sse = sse;
|
||||
}
|
||||
|
||||
OutboundSseEvent create(Message message) {
|
||||
return sse.newEventBuilder()
|
||||
.name(message.getName())
|
||||
.mediaType(MediaType.APPLICATION_JSON_TYPE)
|
||||
.data(message.getType(), message.getData())
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -93,6 +93,8 @@ public class VndMediaType {
|
||||
public static final String REPOSITORY_EXPORT = PREFIX + "repositoryExport" + SUFFIX;
|
||||
public static final String REPOSITORY_EXPORT_INFO = PREFIX + "repositoryExportInfo" + SUFFIX;
|
||||
|
||||
public static final String NOTIFICATION_COLLECTION = PREFIX + "notificationCollection" + SUFFIX;
|
||||
|
||||
private VndMediaType() {
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package sonia.scm.notifications;
|
||||
|
||||
import org.github.sdorra.jse.ShiroExtension;
|
||||
import org.github.sdorra.jse.SubjectAware;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@ExtendWith(ShiroExtension.class)
|
||||
class NotificationSenderTest {
|
||||
|
||||
@Test
|
||||
@SubjectAware("trillian")
|
||||
void shouldUsePrincipal() {
|
||||
CapturingNotificationSender sender = new CapturingNotificationSender();
|
||||
|
||||
Notification notification = new Notification(Type.INFO, "/tricia", "Hello trillian");
|
||||
sender.send(notification);
|
||||
|
||||
assertThat(sender.notification).isSameAs(notification);
|
||||
assertThat(sender.recipient).isEqualTo("trillian");
|
||||
}
|
||||
|
||||
private static class CapturingNotificationSender implements NotificationSender {
|
||||
|
||||
private Notification notification;
|
||||
private String recipient;
|
||||
|
||||
@Override
|
||||
public void send(Notification notification, String recipient) {
|
||||
this.notification = notification;
|
||||
this.recipient = recipient;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package sonia.scm.sse;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ChannelCleanupTaskTest {
|
||||
|
||||
@Mock
|
||||
private ChannelRegistry registry;
|
||||
|
||||
@InjectMocks
|
||||
private ChannelCleanupTask task;
|
||||
|
||||
@Test
|
||||
void shouldRunCleanupFromRegistry() {
|
||||
task.run();
|
||||
|
||||
verify(registry).removeClosedClients();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package sonia.scm.sse;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
class ChannelRegistryTest {
|
||||
|
||||
private ChannelRegistry registry;
|
||||
|
||||
@Nested
|
||||
class ChannelTests {
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
registry = new ChannelRegistry();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCreateNewChannel() {
|
||||
Channel one = registry.channel("one");
|
||||
assertThat(one).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnSameChannelForSameId() {
|
||||
Channel two = registry.channel("two");
|
||||
assertThat(two).isSameAs(registry.channel("two"));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Nested
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class RemoveClosedClientsTests {
|
||||
|
||||
private List<Channel> channels;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
channels = new ArrayList<>();
|
||||
registry = new ChannelRegistry(objectId -> {
|
||||
Channel channel = mock(Channel.class);
|
||||
channels.add(channel);
|
||||
return channel;
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCallRemoveClosedOrTimeoutClientsOnEachChannel() {
|
||||
registry.channel("one");
|
||||
registry.channel("two");
|
||||
registry.channel("three");
|
||||
|
||||
registry.removeClosedClients();
|
||||
|
||||
assertThat(channels)
|
||||
.hasSize(3)
|
||||
.allSatisfy(channel -> verify(channel).removeClosedOrTimeoutClients());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
130
scm-core/src/test/java/sonia/scm/sse/ChannelTest.java
Normal file
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package sonia.scm.sse;
|
||||
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import sonia.scm.security.SessionId;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ChannelTest {
|
||||
|
||||
private Map<Registration,Client> clients;
|
||||
private Channel channel;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
this.clients = new HashMap<>();
|
||||
channel = new Channel("one", registration -> clients.get(registration));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRegisterAndSend() {
|
||||
Client client = register();
|
||||
|
||||
Message message = broadcast("Hello World");
|
||||
|
||||
verify(client).send(message);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotSendToSender() {
|
||||
Client clientOne = register();
|
||||
SessionId sessionOne = SessionId.valueOf("one");
|
||||
when(clientOne.getSessionId()).thenReturn(sessionOne);
|
||||
|
||||
Client clientTwo = register();
|
||||
when(clientTwo.getSessionId()).thenReturn(SessionId.valueOf("two"));
|
||||
|
||||
Message message = broadcast("Hello Two", sessionOne);
|
||||
|
||||
verify(clientOne, never()).send(message);
|
||||
verify(clientTwo).send(message);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRemoveClosedClients() {
|
||||
Client closedClient = register();
|
||||
when(closedClient.isClosed()).thenReturn(true);
|
||||
Client activeClient = register();
|
||||
when(activeClient.getLastUsed()).thenReturn(Instant.now());
|
||||
|
||||
channel.removeClosedOrTimeoutClients();
|
||||
|
||||
Message message = broadcast("Hello active ones");
|
||||
|
||||
verify(closedClient, never()).send(message);
|
||||
verify(activeClient).send(message);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRemoveClientsWhichAreNotUsedWithin30Seconds() {
|
||||
Client timedOutClient = register();
|
||||
when(timedOutClient.getLastUsed()).thenReturn(Instant.now().minus(31L, ChronoUnit.SECONDS));
|
||||
Client activeClient = register();
|
||||
when(activeClient.getLastUsed()).thenReturn(Instant.now().minus(29L, ChronoUnit.SECONDS));
|
||||
|
||||
channel.removeClosedOrTimeoutClients();
|
||||
|
||||
Message message = broadcast("Hello active ones");
|
||||
verify(timedOutClient, never()).send(message);
|
||||
verify(timedOutClient).close();
|
||||
verify(activeClient).send(message);
|
||||
verify(activeClient, never()).close();
|
||||
}
|
||||
|
||||
private Client register() {
|
||||
Registration registration = mock(Registration.class);
|
||||
Client client = mock(Client.class);
|
||||
clients.put(registration, client);
|
||||
channel.register(registration);
|
||||
return client;
|
||||
}
|
||||
|
||||
private Message broadcast(String data) {
|
||||
return broadcast(data, null);
|
||||
}
|
||||
|
||||
private Message broadcast(String data, SessionId sessionId) {
|
||||
Message message = new Message("hello", String.class, data, sessionId);
|
||||
channel.broadcast(message);
|
||||
return message;
|
||||
}
|
||||
|
||||
}
|
||||
138
scm-core/src/test/java/sonia/scm/sse/ClientTest.java
Normal file
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package sonia.scm.sse;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import sonia.scm.security.SessionId;
|
||||
|
||||
import javax.ws.rs.sse.OutboundSseEvent;
|
||||
import javax.ws.rs.sse.Sse;
|
||||
import javax.ws.rs.sse.SseEventSink;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.function.Function;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ClientTest {
|
||||
|
||||
@Mock
|
||||
private SseEventSink eventSink;
|
||||
|
||||
@Mock
|
||||
private SseEventAdapter adapter;
|
||||
|
||||
@Mock
|
||||
private Sse sse;
|
||||
|
||||
@Mock
|
||||
private OutboundSseEvent sseEvent;
|
||||
|
||||
@Mock
|
||||
@SuppressWarnings("rawtypes")
|
||||
private CompletionStage completionStage;
|
||||
|
||||
@Test
|
||||
void shouldSetInitialLastUsed() {
|
||||
Client client = client("one");
|
||||
|
||||
assertThat(client.getLastUsed()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnSessionId() {
|
||||
Client client = client("two");
|
||||
|
||||
assertThat(client.getSessionId()).isEqualTo(SessionId.valueOf("two"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotSendToClosedEventSink() {
|
||||
Client client = client("three");
|
||||
when(eventSink.isClosed()).thenReturn(true);
|
||||
|
||||
client.send(message(42));
|
||||
|
||||
verify(eventSink, never()).send(any(OutboundSseEvent.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCloseEventSink() {
|
||||
Client client = client("one");
|
||||
|
||||
client.close();
|
||||
|
||||
verify(eventSink).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void shouldSendMessage() {
|
||||
Message message = message(21);
|
||||
when(adapter.create(message)).thenReturn(sseEvent);
|
||||
when(eventSink.send(sseEvent)).thenReturn(completionStage);
|
||||
|
||||
Client client = client("one");
|
||||
client.send(message);
|
||||
|
||||
verify(eventSink).send(sseEvent);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void shouldMarkAsClosedOnException() {
|
||||
Message message = message(21);
|
||||
when(adapter.create(message)).thenReturn(sseEvent);
|
||||
when(completionStage.exceptionally(any())).then(ic -> {
|
||||
Function<Exception,?> function = ic.getArgument(0);
|
||||
function.apply(new IllegalStateException("failed"));
|
||||
return null;
|
||||
});
|
||||
when(eventSink.send(sseEvent)).thenReturn(completionStage);
|
||||
|
||||
Client client = client("one");
|
||||
client.send(message);
|
||||
|
||||
verify(eventSink).close();
|
||||
assertThat(client.isExceptionallyClosed()).isTrue();
|
||||
}
|
||||
|
||||
private Message message(int i) {
|
||||
return new Message("count", Integer.class, i);
|
||||
}
|
||||
|
||||
private Client client(String sessionId) {
|
||||
Registration registration = new Registration(SessionId.valueOf(sessionId), sse, eventSink);
|
||||
return new Client(registration, reg -> adapter);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -35,8 +35,6 @@ dependencies {
|
||||
api libraries.junitJupiterApi
|
||||
api libraries.junitJupiterParams
|
||||
api libraries.junitJupiterEngine
|
||||
api libraries.shiroUnit
|
||||
api libraries.shiroExtension
|
||||
|
||||
// junit 4 support
|
||||
api libraries.junitVintageEngine
|
||||
@@ -51,6 +49,10 @@ dependencies {
|
||||
api libraries.mockitoCore
|
||||
api libraries.mockitoJunitJupiter
|
||||
|
||||
// shiro
|
||||
api libraries.shiroExtension
|
||||
api libraries.shiroUnit
|
||||
|
||||
// test rest api's
|
||||
api libraries.resteasyCore
|
||||
api libraries.resteasyValidatorProvider
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package sonia.scm.store;
|
||||
|
||||
import sonia.scm.security.KeyGenerator;
|
||||
import sonia.scm.security.UUIDKeyGenerator;
|
||||
|
||||
import javax.xml.bind.JAXB;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class InMemoryByteDataStore<T> implements DataStore<T> {
|
||||
|
||||
private final Class<T> type;
|
||||
private final KeyGenerator generator = new UUIDKeyGenerator();
|
||||
private final Map<String, byte[]> store = new HashMap<>();
|
||||
|
||||
InMemoryByteDataStore(Class<T> type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String put(T item) {
|
||||
String id = generator.createKey();
|
||||
put(id, item);
|
||||
return id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void put(String id, T item) {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
JAXB.marshal(item, baos);
|
||||
store.put(id, baos.toByteArray());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, T> getAll() {
|
||||
Map<String, T> all = new HashMap<>();
|
||||
for (String id : store.keySet()) {
|
||||
all.put(id, get(id));
|
||||
}
|
||||
return Collections.unmodifiableMap(all);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clear() {
|
||||
store.clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remove(String id) {
|
||||
store.remove(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public T get(String id) {
|
||||
byte[] bytes = store.get(id);
|
||||
if (bytes != null) {
|
||||
return JAXB.unmarshal(new ByteArrayInputStream(bytes), type);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package sonia.scm.store;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Stores data in memory but in contrast to {@link InMemoryDataStoreFactory}
|
||||
* it uses jaxb to marshal and unmarshall the objects.
|
||||
*
|
||||
* @since 2.18.0
|
||||
*/
|
||||
public class InMemoryByteDataStoreFactory implements DataStoreFactory {
|
||||
|
||||
@SuppressWarnings("rawtypes")
|
||||
private final Map<String, InMemoryByteDataStore> stores = new HashMap<>();
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public <T> DataStore<T> getStore(TypedStoreParameters<T> storeParameters) {
|
||||
String name = storeParameters.getName();
|
||||
return stores.computeIfAbsent(name, n -> new InMemoryByteDataStore<T>(storeParameters.getType()));
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
|
||||
package sonia.scm.store;
|
||||
|
||||
import sonia.scm.security.KeyGenerator;
|
||||
@@ -37,7 +37,9 @@ import java.util.Map;
|
||||
* @author Sebastian Sdorra
|
||||
*
|
||||
* @param <T> type of stored object
|
||||
* @deprecated use {@link InMemoryByteDataStore} instead.
|
||||
*/
|
||||
@Deprecated
|
||||
public class InMemoryDataStore<T> implements DataStore<T> {
|
||||
|
||||
private final Map<String, T> store = new HashMap<>();
|
||||
|
||||
@@ -21,14 +21,17 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
|
||||
package sonia.scm.store;
|
||||
|
||||
/**
|
||||
* In memory configuration store factory for testing purposes.
|
||||
*
|
||||
* @author Sebastian Sdorra
|
||||
* @deprecated use {@link InMemoryByteDataStoreFactory} instead.
|
||||
*/
|
||||
@Deprecated
|
||||
@SuppressWarnings("java:S3740")
|
||||
public class InMemoryDataStoreFactory implements DataStoreFactory {
|
||||
|
||||
private InMemoryDataStore store;
|
||||
|
||||
@@ -45,6 +45,7 @@ export * from "./permissions";
|
||||
export * from "./sources";
|
||||
export * from "./import";
|
||||
export * from "./diff";
|
||||
export * from "./notifications";
|
||||
|
||||
export { default as ApiProvider } from "./ApiProvider";
|
||||
export * from "./ApiProvider";
|
||||
|
||||
183
scm-ui/ui-api/src/notifications.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
import { useMe } from "./login";
|
||||
import { useMutation, useQuery, useQueryClient } from "react-query";
|
||||
import { Link, Notification, NotificationCollection } from "@scm-manager/ui-types";
|
||||
import { apiClient } from "./apiclient";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { requiredLink } from "./links";
|
||||
|
||||
export const useNotifications = () => {
|
||||
const { data: me } = useMe();
|
||||
const link = (me?._links["notifications"] as Link)?.href;
|
||||
const { data, error, isLoading, refetch } = useQuery<NotificationCollection, Error>(
|
||||
"notifications",
|
||||
() => apiClient.get(link).then(response => response.json()),
|
||||
{
|
||||
enabled: !!link
|
||||
}
|
||||
);
|
||||
|
||||
const memoizedRefetch = useCallback(() => {
|
||||
return refetch().then(r => r.data);
|
||||
}, [refetch]);
|
||||
|
||||
return {
|
||||
data,
|
||||
error,
|
||||
isLoading,
|
||||
refetch: memoizedRefetch
|
||||
};
|
||||
};
|
||||
|
||||
export const useDismissNotification = (notification: Notification) => {
|
||||
const queryClient = useQueryClient();
|
||||
const link = requiredLink(notification, "dismiss");
|
||||
const { data, isLoading, error, mutate } = useMutation<Response, Error>(() => apiClient.delete(link), {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries("notifications");
|
||||
}
|
||||
});
|
||||
return {
|
||||
isLoading,
|
||||
error,
|
||||
dismiss: () => mutate(),
|
||||
isCleared: !!data
|
||||
};
|
||||
};
|
||||
|
||||
export const useClearNotifications = (notificationCollection: NotificationCollection) => {
|
||||
const queryClient = useQueryClient();
|
||||
const link = requiredLink(notificationCollection, "clear");
|
||||
const { data, isLoading, error, mutate } = useMutation<Response, Error>(() => apiClient.delete(link), {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries("notifications");
|
||||
}
|
||||
});
|
||||
return {
|
||||
isLoading,
|
||||
error,
|
||||
clear: () => mutate(),
|
||||
isCleared: !!data
|
||||
};
|
||||
};
|
||||
|
||||
const isEqual = (left: Notification, right: Notification) => {
|
||||
return left === right || (left.message === right.message && left.createdAt === right.createdAt);
|
||||
};
|
||||
|
||||
export const useNotificationSubscription = (
|
||||
refetch: () => Promise<NotificationCollection | undefined>,
|
||||
notificationCollection?: NotificationCollection
|
||||
) => {
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [disconnectedAt, setDisconnectedAt] = useState<Date>();
|
||||
const link = (notificationCollection?._links.subscribe as Link)?.href;
|
||||
|
||||
const onVisible = useCallback(() => {
|
||||
// we don't need to catch the error,
|
||||
// because if the refetch throws an error the parent useNotifications should catch it
|
||||
refetch().then(collection => {
|
||||
if (collection) {
|
||||
const newNotifications = collection._embedded.notifications.filter(n => {
|
||||
return disconnectedAt && disconnectedAt < new Date(n.createdAt);
|
||||
});
|
||||
if (newNotifications.length > 0) {
|
||||
setNotifications(previous => [...previous, ...newNotifications]);
|
||||
}
|
||||
setDisconnectedAt(undefined);
|
||||
}
|
||||
});
|
||||
}, [disconnectedAt, refetch]);
|
||||
|
||||
const onHide = useCallback(() => {
|
||||
setDisconnectedAt(new Date());
|
||||
}, []);
|
||||
|
||||
const received = useCallback(
|
||||
(notification: Notification) => {
|
||||
setNotifications(previous => [...previous, notification]);
|
||||
refetch();
|
||||
},
|
||||
[refetch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (link) {
|
||||
let cancel: () => void;
|
||||
|
||||
const disconnect = () => {
|
||||
if (cancel) {
|
||||
cancel();
|
||||
}
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
disconnect();
|
||||
cancel = apiClient.subscribe(link, {
|
||||
notification: event => {
|
||||
received(JSON.parse(event.data));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
onVisible();
|
||||
} else {
|
||||
onHide();
|
||||
}
|
||||
};
|
||||
|
||||
if (document.visibilityState === "visible") {
|
||||
connect();
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
disconnect();
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}
|
||||
}, [link, onVisible, onHide, received]);
|
||||
|
||||
const remove = useCallback(
|
||||
(notification: Notification) => {
|
||||
setNotifications(oldNotifications => [...oldNotifications.filter(n => !isEqual(n, notification))]);
|
||||
},
|
||||
[setNotifications]
|
||||
);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setNotifications([]);
|
||||
}, [setNotifications]);
|
||||
|
||||
return {
|
||||
notifications,
|
||||
remove,
|
||||
clear
|
||||
};
|
||||
};
|
||||
@@ -77782,6 +77782,8 @@ exports[`Storyshots Toast Danger 1`] = `null`;
|
||||
|
||||
exports[`Storyshots Toast Info 1`] = `null`;
|
||||
|
||||
exports[`Storyshots Toast Multiple 1`] = `null`;
|
||||
|
||||
exports[`Storyshots Toast Open/Close 1`] = `
|
||||
<div
|
||||
style={
|
||||
|
||||
49
scm-ui/ui-components/src/devices.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
// https://bulma.io/documentation/overview/responsiveness/
|
||||
|
||||
export type Device = {
|
||||
width: number;
|
||||
};
|
||||
|
||||
export const devices = {
|
||||
mobile: {
|
||||
width: 768
|
||||
},
|
||||
tablet: {
|
||||
width: 769
|
||||
},
|
||||
desktop: {
|
||||
width: 1024
|
||||
},
|
||||
widescreen: {
|
||||
width: 1216
|
||||
},
|
||||
fullhd: {
|
||||
width: 1408
|
||||
}
|
||||
};
|
||||
|
||||
export type DeviceType = keyof typeof devices;
|
||||
@@ -79,11 +79,11 @@ export { default as CommaSeparatedList } from "./CommaSeparatedList";
|
||||
export { default as SplitAndReplace, Replacement } from "./SplitAndReplace";
|
||||
export { regExpPattern as changesetShortLinkRegex } from "./markdown/remarkChangesetShortLinkParser";
|
||||
export * from "./markdown/PluginApi";
|
||||
export * from "./devices";
|
||||
|
||||
export { default as comparators } from "./comparators";
|
||||
|
||||
export { isDevBuild, createAttributesForTesting } from "./devBuild";
|
||||
|
||||
export * from "./avatar";
|
||||
export * from "./buttons";
|
||||
export * from "./config";
|
||||
|
||||
@@ -129,7 +129,7 @@ class PrimaryNavigation extends React.Component<Props> {
|
||||
const navigationItems = this.createNavigationItems();
|
||||
|
||||
return (
|
||||
<nav className="tabs is-boxed">
|
||||
<nav className="tabs is-boxed mb-0">
|
||||
<ul>{navigationItems}</ul>
|
||||
</nav>
|
||||
);
|
||||
|
||||
58
scm-ui/ui-components/src/toast/ToastArea.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
import React, { FC } from "react";
|
||||
import usePortalRootElement from "../usePortalRootElement";
|
||||
import { createPortal } from "react-dom";
|
||||
import styled from "styled-components";
|
||||
|
||||
const Container = styled.div`
|
||||
z-index: 99999;
|
||||
position: fixed;
|
||||
right: 1.5rem;
|
||||
bottom: 1.5rem;
|
||||
|
||||
animation: 0.5s slide-up;
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
bottom: -20rem;
|
||||
}
|
||||
to {
|
||||
bottom: 1.5rem;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const ToastArea: FC = ({ children }) => {
|
||||
const rootElement = usePortalRootElement("toastRoot");
|
||||
if (!rootElement) {
|
||||
// portal not yet ready
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPortal(<Container>{children}</Container>, rootElement);
|
||||
};
|
||||
|
||||
export default ToastArea;
|
||||
66
scm-ui/ui-components/src/toast/ToastNotification.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
import { getTheme, Themeable, ToastThemeContext, Type } from "./themes";
|
||||
import styled from "styled-components";
|
||||
import React, { FC } from "react";
|
||||
|
||||
type Props = {
|
||||
type: Type;
|
||||
title: string;
|
||||
close?: () => void;
|
||||
};
|
||||
|
||||
const Container = styled.div<Themeable>`
|
||||
color: ${props => props.theme.primary};
|
||||
background-color: ${props => props.theme.secondary};
|
||||
max-width: 18rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 5px;
|
||||
padding: 1.5rem;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0 !important;
|
||||
|
||||
& > p {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const Title = styled.h1<Themeable>`
|
||||
margin-bottom: 0.25rem;
|
||||
font-weight: bold;
|
||||
`;
|
||||
|
||||
const ToastNotification: FC<Props> = ({ children, title, type, close }) => {
|
||||
const theme = getTheme(type);
|
||||
return (
|
||||
<Container className="notification" theme={theme}>
|
||||
{ close ? <button className="delete" onClick={close} /> : null }
|
||||
<Title theme={theme}>{title}</Title>
|
||||
<ToastThemeContext.Provider value={theme}>{children}</ToastThemeContext.Provider>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToastNotification;
|
||||
@@ -27,6 +27,8 @@ import Toast from "./Toast";
|
||||
import ToastButtons from "./ToastButtons";
|
||||
import ToastButton from "./ToastButton";
|
||||
import { types } from "./themes";
|
||||
import ToastArea from "./ToastArea";
|
||||
import ToastNotification from "./ToastNotification";
|
||||
|
||||
const toastStories = storiesOf("Toast", module);
|
||||
|
||||
@@ -87,3 +89,13 @@ types.forEach(type => {
|
||||
</Toast>
|
||||
));
|
||||
});
|
||||
|
||||
toastStories.add("Multiple", () => (
|
||||
<ToastArea>
|
||||
{types.map(type => (
|
||||
<ToastNotification key={type} type={type} title="New notification">
|
||||
<p>The notification received.</p>
|
||||
</ToastNotification>
|
||||
))}
|
||||
</ToastArea>
|
||||
));
|
||||
|
||||
@@ -23,5 +23,8 @@
|
||||
*/
|
||||
|
||||
export { default as Toast } from "./Toast";
|
||||
export { default as ToastArea } from "./ToastArea";
|
||||
export { default as ToastNotification } from "./ToastNotification";
|
||||
export { Type as ToastType } from "./themes";
|
||||
export { default as ToastButton } from "./ToastButton";
|
||||
export { default as ToastButtons } from "./ToastButtons";
|
||||
|
||||
@@ -34,9 +34,9 @@ export type Themeable = {
|
||||
theme: ToastTheme;
|
||||
};
|
||||
|
||||
export type Type = "info" | "primary" | "success" | "warning" | "danger";
|
||||
export const types = ["info", "primary", "success", "warning", "danger"] as const;
|
||||
|
||||
export const types: Type[] = ["info", "primary", "success", "warning", "danger"];
|
||||
export type Type = typeof types[number];
|
||||
|
||||
const themes: { [name in Type]: ToastTheme } = {
|
||||
info: {
|
||||
|
||||
@@ -572,6 +572,26 @@ ul.is-separated {
|
||||
&.border-is-yellow td:first-child {
|
||||
border-left-color: $yellow;
|
||||
}
|
||||
|
||||
&.is-primary td:first-child {
|
||||
border-left-color: $primary;
|
||||
}
|
||||
&.is-success td:first-child {
|
||||
border-left-color: $success;
|
||||
}
|
||||
&.is-warning td:first-child {
|
||||
border-left-color: $warning;
|
||||
}
|
||||
&.is-danger td:first-child {
|
||||
border-left-color: $danger;
|
||||
}
|
||||
&.is-info td:first-child {
|
||||
border-left-color: $info;
|
||||
}
|
||||
&.is-link td:first-child {
|
||||
border-left-color: $link;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
td {
|
||||
background-color: whitesmoke;
|
||||
@@ -866,4 +886,12 @@ form .field:not(.is-grouped) {
|
||||
background: $warning-25;
|
||||
}
|
||||
|
||||
.small-loading-spinner {
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
&:after {
|
||||
@include loader;
|
||||
}
|
||||
}
|
||||
|
||||
@import "bulma-popover/css/bulma-popover";
|
||||
|
||||
38
scm-ui/ui-types/src/Notifications.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
import { Embedded, HalRepresentation, HalRepresentationWithEmbedded } from "./hal";
|
||||
|
||||
export type Notification = HalRepresentation & {
|
||||
createdAt: string;
|
||||
type: "INFO" | "SUCCESS" | "WARNING" | "ERROR";
|
||||
link: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
type EmbeddedNotifications = {
|
||||
notifications: Notification[];
|
||||
} & Embedded;
|
||||
|
||||
export type NotificationCollection = HalRepresentationWithEmbedded<EmbeddedNotifications>;
|
||||
@@ -66,3 +66,4 @@ export * from "./LoginInfo";
|
||||
export * from "./Admin";
|
||||
|
||||
export * from "./Diff";
|
||||
export * from "./Notifications";
|
||||
|
||||
@@ -124,5 +124,13 @@
|
||||
},
|
||||
"importLog": {
|
||||
"title": "Importprotokoll"
|
||||
},
|
||||
"notifications": {
|
||||
"toastTitle": "Benachrichtigung",
|
||||
"xMore": "+{{ count }} Benachrichtigung",
|
||||
"xMore_plural": "+{{ count }} Benachrichtigungen",
|
||||
"empty": "Keine Benachrichtigungen",
|
||||
"dismiss": "Löschen",
|
||||
"dismissAll": "Alle löschen"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,5 +125,13 @@
|
||||
},
|
||||
"importLog": {
|
||||
"title": "Import Log"
|
||||
},
|
||||
"notifications": {
|
||||
"toastTitle": "Notification",
|
||||
"xMore": "+{{ count }} Notification",
|
||||
"xMore_plural": "+{{ count }} Notifications",
|
||||
"empty": "No notifications",
|
||||
"dismiss": "Dismiss",
|
||||
"dismissAll": "Dismiss all"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { ErrorPage, Footer, Header, Loading, PrimaryNavigation } from "@scm-manager/ui-components";
|
||||
import Login from "./Login";
|
||||
import { useSubject, useIndex } from "@scm-manager/ui-api";
|
||||
import Notifications from "./Notifications";
|
||||
|
||||
const App: FC = () => {
|
||||
const { data: index } = useIndex();
|
||||
@@ -41,7 +42,6 @@ const App: FC = () => {
|
||||
|
||||
// authenticated means authorized, we stick on authenticated for compatibility reasons
|
||||
const authenticated = isAuthenticated || isAnonymous;
|
||||
const navigation = authenticated ? <PrimaryNavigation links={index._links} /> : "";
|
||||
|
||||
if (!authenticated && !isLoading) {
|
||||
content = <Login />;
|
||||
@@ -55,7 +55,14 @@ const App: FC = () => {
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
<Header>{navigation}</Header>
|
||||
<Header>
|
||||
{authenticated ? (
|
||||
<div className="is-flex is-justify-content-space-between is-flex-wrap-nowrap ">
|
||||
<PrimaryNavigation links={index._links} />
|
||||
<Notifications />
|
||||
</div>
|
||||
) : null}
|
||||
</Header>
|
||||
{content}
|
||||
{authenticated ? <Footer me={me} version={index.version} links={index._links} /> : null}
|
||||
</div>
|
||||
|
||||
353
scm-ui/ui-webapp/src/containers/Notifications.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
import React, { FC, useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Notification as InfoNotification,
|
||||
ErrorNotification,
|
||||
Icon,
|
||||
ToastArea,
|
||||
ToastNotification,
|
||||
ToastType,
|
||||
Loading,
|
||||
DateFromNow,
|
||||
devices
|
||||
} from "@scm-manager/ui-components";
|
||||
import styled from "styled-components";
|
||||
import {
|
||||
useClearNotifications,
|
||||
useDismissNotification,
|
||||
useNotifications,
|
||||
useNotificationSubscription
|
||||
} from "@scm-manager/ui-api";
|
||||
import { Notification, NotificationCollection } from "@scm-manager/ui-types";
|
||||
import { useHistory, Link } from "react-router-dom";
|
||||
import classNames from "classnames";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const Bell = styled(Icon)`
|
||||
font-size: 1.5rem;
|
||||
`;
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
|
||||
@media screen and (max-width: ${devices.desktop.width}px) {
|
||||
padding-right: 1rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const DropDownMenu = styled.div`
|
||||
min-width: 35rem;
|
||||
|
||||
@media screen and (max-width: ${devices.mobile.width}px) {
|
||||
min-width: 25rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: ${devices.desktop.width}px) {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
&:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
border-style: solid;
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
width: 0;
|
||||
top: 0;
|
||||
right: 0.9rem;
|
||||
border-color: transparent;
|
||||
border-bottom-color: white;
|
||||
border-left-color: white;
|
||||
border-width: 0.4rem;
|
||||
transform-origin: center;
|
||||
transform: rotate(135deg);
|
||||
}
|
||||
`;
|
||||
|
||||
const VerticalCenteredTd = styled.td`
|
||||
vertical-align: middle !important;
|
||||
`;
|
||||
|
||||
const DateColumn = styled(VerticalCenteredTd)`
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const DismissColumn = styled.td`
|
||||
vertical-align: middle !important;
|
||||
width: 2rem;
|
||||
`;
|
||||
|
||||
type EntryProps = {
|
||||
notification: Notification;
|
||||
removeToast: (notification: Notification) => void;
|
||||
};
|
||||
|
||||
const NotificationEntry: FC<EntryProps> = ({ notification, removeToast }) => {
|
||||
const history = useHistory();
|
||||
const { isLoading, error, dismiss } = useDismissNotification(notification);
|
||||
const [t] = useTranslation("commons");
|
||||
|
||||
const remove = () => {
|
||||
removeToast(notification);
|
||||
dismiss();
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return <ErrorNotification error={error} />;
|
||||
}
|
||||
return (
|
||||
<tr className={`is-${color(notification)}`}>
|
||||
<VerticalCenteredTd onClick={() => history.push(notification.link)} className="has-cursor-pointer">
|
||||
<NotificationMessage message={notification.message} />
|
||||
</VerticalCenteredTd>
|
||||
<DateColumn className="has-text-right">
|
||||
<DateFromNow date={notification.createdAt} />
|
||||
</DateColumn>
|
||||
<DismissColumn className="is-darker">
|
||||
{isLoading ? (
|
||||
<div className="small-loading-spinner" />
|
||||
) : (
|
||||
<Icon
|
||||
name="trash"
|
||||
color="black"
|
||||
className="has-cursor-pointer"
|
||||
title={t("notifications.dismiss")}
|
||||
onClick={remove}
|
||||
/>
|
||||
)}
|
||||
</DismissColumn>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
const DismissAllButton = styled(Button)`
|
||||
&:hover > * {
|
||||
color: white !important;
|
||||
}
|
||||
`;
|
||||
|
||||
type ClearEntryProps = {
|
||||
notifications: NotificationCollection;
|
||||
clearToasts: () => void;
|
||||
};
|
||||
|
||||
const ClearEntry: FC<ClearEntryProps> = ({ notifications, clearToasts }) => {
|
||||
const { isLoading, error, clear: clearStore } = useClearNotifications(notifications);
|
||||
const [t] = useTranslation("commons");
|
||||
const clear = () => {
|
||||
clearToasts();
|
||||
clearStore();
|
||||
};
|
||||
return (
|
||||
<div className="dropdown-item has-text-centered">
|
||||
<ErrorNotification error={error} />
|
||||
<DismissAllButton className="is-outlined" color="link" loading={isLoading} action={clear}>
|
||||
<Icon color="link" name="trash" className="mr-1" /> {t("notifications.dismissAll")}
|
||||
</DismissAllButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NotificationList: FC<Props> = ({ data, clear, remove }) => {
|
||||
const [t] = useTranslation("commons");
|
||||
const clearLink = data._links.clear;
|
||||
|
||||
const all = [...data._embedded.notifications].reverse();
|
||||
const top = all.slice(0, 6);
|
||||
|
||||
return (
|
||||
<div className="dropdown-content p-0">
|
||||
<table className="table mb-0 card-table">
|
||||
<tbody>
|
||||
{top.map((n, i) => (
|
||||
<NotificationEntry key={i} notification={n} removeToast={remove} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{all.length > 6 ? (
|
||||
<p className="has-text-centered has-text-grey">{t("notifications.xMore", { count: all.length - 6 })}</p>
|
||||
) : null}
|
||||
{clearLink ? <ClearEntry notifications={data} clearToasts={clear} /> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DropdownMenuContainer: FC = ({ children }) => <div className="dropdown-content p-4">{children}</div>;
|
||||
|
||||
const NoNotifications: FC = () => {
|
||||
const [t] = useTranslation("commons");
|
||||
return (
|
||||
<DropdownMenuContainer>
|
||||
<InfoNotification type="info">{t("notifications.empty")}</InfoNotification>
|
||||
</DropdownMenuContainer>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
data: NotificationCollection;
|
||||
remove: (notification: Notification) => void;
|
||||
clear: () => void;
|
||||
};
|
||||
|
||||
const NotificationDropDown: FC<Props> = ({ data, remove, clear }) => (
|
||||
<>
|
||||
{data._embedded.notifications.length > 0 ? (
|
||||
<NotificationList data={data} remove={remove} clear={clear} />
|
||||
) : (
|
||||
<NoNotifications />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const color = (notification: Notification) => {
|
||||
let c: string = notification.type.toLowerCase();
|
||||
// We use the color danger for an error.
|
||||
// All other notification types are matching a color, except error which must be mapped to danger.
|
||||
if (c === "error") {
|
||||
c = "danger";
|
||||
}
|
||||
return c;
|
||||
};
|
||||
|
||||
const NotificationMessage: FC<{ message: string }> = ({ message }) => {
|
||||
const [t] = useTranslation("plugins");
|
||||
return t("notifications." + message, message);
|
||||
};
|
||||
|
||||
type SubscriptionProps = {
|
||||
notifications: Notification[];
|
||||
remove: (notification: Notification) => void;
|
||||
};
|
||||
|
||||
const NotificationSubscription: FC<SubscriptionProps> = ({ notifications, remove }) => {
|
||||
const [t] = useTranslation("commons");
|
||||
|
||||
const top = [...notifications].slice(-3);
|
||||
|
||||
return (
|
||||
<ToastArea>
|
||||
{top.map((notification, i) => (
|
||||
<ToastNotification
|
||||
key={i}
|
||||
type={color(notification) as ToastType}
|
||||
title={t("notifications.toastTitle")}
|
||||
close={() => remove(notification)}
|
||||
>
|
||||
<p>
|
||||
<Link to={notification.link}>
|
||||
<NotificationMessage message={notification.message} />
|
||||
</Link>
|
||||
</p>
|
||||
</ToastNotification>
|
||||
))}
|
||||
</ToastArea>
|
||||
);
|
||||
};
|
||||
|
||||
const BellNotificationContainer = styled.div`
|
||||
position: relative;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
`;
|
||||
|
||||
type NotificationCounterProps = {
|
||||
count: number;
|
||||
};
|
||||
|
||||
const NotificationCounter = styled.span<NotificationCounterProps>`
|
||||
position: absolute;
|
||||
top: -0.5rem;
|
||||
right: ${props => (props.count < 10 ? "0" : "-0.25")}rem;
|
||||
`;
|
||||
|
||||
type BellNotificationIconProps = {
|
||||
data?: NotificationCollection;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const BellNotificationIcon: FC<BellNotificationIconProps> = ({ data, onClick }) => {
|
||||
const counter = data?._embedded.notifications.length || 0;
|
||||
return (
|
||||
<BellNotificationContainer onClick={onClick}>
|
||||
<Bell iconStyle={counter === 0 ? "far" : "fas"} name="bell" color="white" />
|
||||
{counter > 0 ? <NotificationCounter count={counter}>{counter < 100 ? counter : "∞"}</NotificationCounter> : null}
|
||||
</BellNotificationContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const LoadingBox: FC = () => (
|
||||
<div className="box">
|
||||
<Loading />
|
||||
</div>
|
||||
);
|
||||
|
||||
const ErrorBox: FC<{ error: Error | null }> = ({ error }) => {
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<DropdownMenuContainer>
|
||||
<ErrorNotification error={error} />
|
||||
</DropdownMenuContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const Notifications: FC = () => {
|
||||
const { data, isLoading, error, refetch } = useNotifications();
|
||||
const { notifications, remove, clear } = useNotificationSubscription(refetch, data);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
const close = () => setOpen(false);
|
||||
window.addEventListener("click", close);
|
||||
return () => window.removeEventListener("click", close);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<NotificationSubscription notifications={notifications} remove={remove} />
|
||||
<div
|
||||
className={classNames("is-align-self-flex-end", "dropdown", "is-right", "is-hoverable", {
|
||||
"is-active": open
|
||||
})}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Container className="dropdown-trigger">
|
||||
<BellNotificationIcon data={data} onClick={() => setOpen(o => !o)} />
|
||||
</Container>
|
||||
<DropDownMenu className="dropdown-menu" id="dropdown-menu" role="menu">
|
||||
<ErrorBox error={error} />
|
||||
{isLoading ? <LoadingBox /> : null}
|
||||
{data ? <NotificationDropDown data={data} remove={remove} clear={clear} /> : null}
|
||||
</DropDownMenu>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Notifications;
|
||||
@@ -84,6 +84,7 @@ public class MeDtoFactory extends HalAppenderMapper {
|
||||
|
||||
private MeDto createDto(User user) {
|
||||
Links.Builder linksBuilder = linkingTo().self(resourceLinks.me().self());
|
||||
|
||||
if (UserPermissions.delete(user).isPermitted()) {
|
||||
linksBuilder.single(link("delete", resourceLinks.me().delete(user.getName())));
|
||||
}
|
||||
@@ -100,6 +101,8 @@ public class MeDtoFactory extends HalAppenderMapper {
|
||||
linksBuilder.single(link("apiKeys", resourceLinks.apiKeyCollection().self(user.getName())));
|
||||
}
|
||||
|
||||
linksBuilder.single(link("notifications", resourceLinks.me().notifications()));
|
||||
|
||||
Embedded.Builder embeddedBuilder = embeddedBuilder();
|
||||
applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), new Me(), user);
|
||||
|
||||
|
||||
@@ -65,13 +65,15 @@ public class MeResource {
|
||||
private final PasswordService passwordService;
|
||||
|
||||
private final Provider<ApiKeyResource> apiKeyResourceProvider;
|
||||
private final Provider<NotificationResource> notificationResourceProvider;
|
||||
|
||||
@Inject
|
||||
public MeResource(MeDtoFactory meDtoFactory, UserManager userManager, PasswordService passwordService, Provider<ApiKeyResource> apiKeyResourceProvider) {
|
||||
public MeResource(MeDtoFactory meDtoFactory, UserManager userManager, PasswordService passwordService, Provider<ApiKeyResource> apiKeyResourceProvider, Provider<NotificationResource> notificationResourceProvider) {
|
||||
this.meDtoFactory = meDtoFactory;
|
||||
this.userManager = userManager;
|
||||
this.passwordService = passwordService;
|
||||
this.apiKeyResourceProvider = apiKeyResourceProvider;
|
||||
this.notificationResourceProvider = notificationResourceProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,4 +146,9 @@ public class MeResource {
|
||||
public ApiKeyResource apiKeys() {
|
||||
return apiKeyResourceProvider.get();
|
||||
}
|
||||
|
||||
@Path("notifications")
|
||||
public NotificationResource notifications() {
|
||||
return notificationResourceProvider.get();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package sonia.scm.api.v2.resources;
|
||||
|
||||
import de.otto.edison.hal.HalRepresentation;
|
||||
import de.otto.edison.hal.Links;
|
||||
import lombok.Data;
|
||||
import sonia.scm.notifications.StoredNotification;
|
||||
import sonia.scm.notifications.Type;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Data
|
||||
public class NotificationDto extends HalRepresentation {
|
||||
|
||||
private Instant createdAt;
|
||||
private Type type;
|
||||
private String link;
|
||||
private String message;
|
||||
|
||||
public NotificationDto(StoredNotification notification, Links links) {
|
||||
super(links);
|
||||
this.type = notification.getType();
|
||||
this.link = notification.getLink();
|
||||
this.message = notification.getMessage();
|
||||
this.createdAt = notification.getCreatedAt();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package sonia.scm.api.v2.resources;
|
||||
|
||||
import de.otto.edison.hal.Embedded;
|
||||
import de.otto.edison.hal.HalRepresentation;
|
||||
import de.otto.edison.hal.Link;
|
||||
import de.otto.edison.hal.Links;
|
||||
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import sonia.scm.notifications.NotificationChannelId;
|
||||
import sonia.scm.notifications.NotificationStore;
|
||||
import sonia.scm.notifications.StoredNotification;
|
||||
import sonia.scm.security.SessionId;
|
||||
import sonia.scm.sse.Channel;
|
||||
import sonia.scm.sse.ChannelRegistry;
|
||||
import sonia.scm.sse.Registration;
|
||||
import sonia.scm.web.VndMediaType;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import javax.ws.rs.DELETE;
|
||||
import javax.ws.rs.GET;
|
||||
import javax.ws.rs.Path;
|
||||
import javax.ws.rs.PathParam;
|
||||
import javax.ws.rs.Produces;
|
||||
import javax.ws.rs.QueryParam;
|
||||
import javax.ws.rs.core.Context;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import javax.ws.rs.core.Response;
|
||||
import javax.ws.rs.core.UriInfo;
|
||||
import javax.ws.rs.sse.Sse;
|
||||
import javax.ws.rs.sse.SseEventSink;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@OpenAPIDefinition(tags = {
|
||||
@Tag(name = "Notifications", description = "Notification related endpoints")
|
||||
})
|
||||
public class NotificationResource {
|
||||
|
||||
private final NotificationStore store;
|
||||
private final ChannelRegistry channelRegistry;
|
||||
|
||||
@Inject
|
||||
public NotificationResource(NotificationStore store, ChannelRegistry channelRegistry) {
|
||||
this.store = store;
|
||||
this.channelRegistry = channelRegistry;
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("")
|
||||
@Produces(VndMediaType.NOTIFICATION_COLLECTION)
|
||||
@Operation(
|
||||
summary = "Notifications",
|
||||
description = "Returns all notifications for the current user",
|
||||
tags = "Notifications",
|
||||
operationId = "notifications_get_all"
|
||||
)
|
||||
@ApiResponse(
|
||||
responseCode = "200",
|
||||
description = "success",
|
||||
content = @Content(
|
||||
mediaType = VndMediaType.NOTIFICATION_COLLECTION,
|
||||
schema = @Schema(implementation = HalRepresentation.class)
|
||||
)
|
||||
)
|
||||
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
|
||||
@ApiResponse(
|
||||
responseCode = "500",
|
||||
description = "internal server error",
|
||||
content = @Content(
|
||||
mediaType = VndMediaType.ERROR_TYPE,
|
||||
schema = @Schema(implementation = ErrorDto.class)
|
||||
))
|
||||
public HalRepresentation getAll(@Context UriInfo uriInfo) {
|
||||
return new HalRepresentation(
|
||||
createCollectionLinks(uriInfo),
|
||||
createEmbeddedNotifications(uriInfo)
|
||||
);
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("{id}")
|
||||
@Operation(
|
||||
summary = "Dismiss",
|
||||
description = "Dismiss the notification with the given id",
|
||||
tags = "Notifications",
|
||||
operationId = "notifications_dismiss"
|
||||
)
|
||||
@ApiResponse(
|
||||
responseCode = "204",
|
||||
description = "no content"
|
||||
)
|
||||
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
|
||||
@ApiResponse(
|
||||
responseCode = "500",
|
||||
description = "internal server error",
|
||||
content = @Content(
|
||||
mediaType = VndMediaType.ERROR_TYPE,
|
||||
schema = @Schema(implementation = ErrorDto.class)
|
||||
))
|
||||
public Response dismiss(@PathParam("id") String id) {
|
||||
store.remove(id);
|
||||
return Response.noContent().build();
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("")
|
||||
@Operation(
|
||||
summary = "Dismiss all",
|
||||
description = "Dismiss all notifications for the current user",
|
||||
tags = "Notifications",
|
||||
operationId = "notifications_dismiss_all"
|
||||
)
|
||||
@ApiResponse(
|
||||
responseCode = "204",
|
||||
description = "no content"
|
||||
)
|
||||
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
|
||||
@ApiResponse(
|
||||
responseCode = "500",
|
||||
description = "internal server error",
|
||||
content = @Content(
|
||||
mediaType = VndMediaType.ERROR_TYPE,
|
||||
schema = @Schema(implementation = ErrorDto.class)
|
||||
))
|
||||
public Response dismissAll() {
|
||||
store.clear();
|
||||
return Response.noContent().build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("subscribe")
|
||||
@Produces(MediaType.SERVER_SENT_EVENTS)
|
||||
@Operation(
|
||||
summary = "Subscribe",
|
||||
description = "Subscribe to the sse event stream of notification for the current user",
|
||||
tags = "Notifications",
|
||||
operationId = "notifications_subscribe"
|
||||
)
|
||||
@ApiResponse(
|
||||
responseCode = "200"
|
||||
)
|
||||
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
|
||||
@ApiResponse(
|
||||
responseCode = "500",
|
||||
description = "internal server error",
|
||||
content = @Content(
|
||||
mediaType = VndMediaType.ERROR_TYPE,
|
||||
schema = @Schema(implementation = ErrorDto.class)
|
||||
))
|
||||
public void subscribe(@Context Sse sse, @Context SseEventSink eventSink, @QueryParam(SessionId.PARAMETER) SessionId sessionId) {
|
||||
Channel channel = channelRegistry.channel(NotificationChannelId.current());
|
||||
channel.register(new Registration(sessionId, sse, eventSink));
|
||||
}
|
||||
|
||||
private Embedded createEmbeddedNotifications(UriInfo uriInfo) {
|
||||
List<NotificationDto> notifications = store.getAll()
|
||||
.stream()
|
||||
.map(n -> map(uriInfo, n))
|
||||
.collect(Collectors.toList());
|
||||
return Embedded.embedded("notifications", notifications);
|
||||
}
|
||||
|
||||
private NotificationDto map(UriInfo uriInfo, StoredNotification storedNotification) {
|
||||
String href = uriInfo.getAbsolutePathBuilder().path(storedNotification.getId()).build().toASCIIString();
|
||||
Links links = Links.linkingTo().single(Link.link("dismiss", href)).build();
|
||||
return new NotificationDto(storedNotification, links);
|
||||
}
|
||||
|
||||
private Links createCollectionLinks(UriInfo uriInfo) {
|
||||
String self = selfLink(uriInfo);
|
||||
return Links.linkingTo()
|
||||
.self(self)
|
||||
.single(Link.link("clear", self))
|
||||
.single(Link.link("subscribe", subscribeLink(uriInfo)))
|
||||
.build();
|
||||
}
|
||||
|
||||
private String selfLink(UriInfo uriInfo) {
|
||||
return uriInfo.getAbsolutePath().toASCIIString();
|
||||
}
|
||||
|
||||
private String subscribeLink(UriInfo uriInfo) {
|
||||
return uriInfo.getRequestUriBuilder().path("subscribe").build().toASCIIString();
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import sonia.scm.ConcurrentModificationException;
|
||||
import sonia.scm.NotFoundException;
|
||||
import sonia.scm.Type;
|
||||
import sonia.scm.importexport.ExportFileExtensionResolver;
|
||||
import sonia.scm.importexport.ExportNotificationHandler;
|
||||
import sonia.scm.importexport.ExportService;
|
||||
import sonia.scm.importexport.FullScmRepositoryExporter;
|
||||
import sonia.scm.importexport.RepositoryImportExportEncryption;
|
||||
@@ -98,6 +99,7 @@ public class RepositoryExportResource {
|
||||
private final RepositoryExportInformationToDtoMapper informationToDtoMapper;
|
||||
private final ExportFileExtensionResolver fileExtensionResolver;
|
||||
private final ResourceLinks resourceLinks;
|
||||
private final ExportNotificationHandler notificationHandler;
|
||||
|
||||
@Inject
|
||||
public RepositoryExportResource(RepositoryManager manager,
|
||||
@@ -108,8 +110,8 @@ public class RepositoryExportResource {
|
||||
RepositoryExportInformationToDtoMapper informationToDtoMapper,
|
||||
ExportFileExtensionResolver fileExtensionResolver,
|
||||
ResourceLinks resourceLinks,
|
||||
MeterRegistry registry
|
||||
) {
|
||||
MeterRegistry registry,
|
||||
ExportNotificationHandler notificationHandler) {
|
||||
this.manager = manager;
|
||||
this.serviceFactory = serviceFactory;
|
||||
this.fullScmRepositoryExporter = fullScmRepositoryExporter;
|
||||
@@ -119,6 +121,7 @@ public class RepositoryExportResource {
|
||||
this.fileExtensionResolver = fileExtensionResolver;
|
||||
this.resourceLinks = resourceLinks;
|
||||
this.repositoryExportHandler = this.createExportHandlerPool(registry);
|
||||
this.notificationHandler = notificationHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -312,11 +315,7 @@ public class RepositoryExportResource {
|
||||
Repository repository = getVerifiedRepository(namespace, name);
|
||||
RepositoryPermissions.export(repository).check();
|
||||
checkRepositoryIsAlreadyExporting(repository);
|
||||
return exportAsync(repository, request.isAsync(), () -> {
|
||||
Response response = exportFullRepository(repository, request.getPassword(), request.isAsync());
|
||||
exportService.setExportFinished(repository);
|
||||
return response;
|
||||
});
|
||||
return exportAsync(repository, request.isAsync(), () -> exportFullRepository(repository, request.getPassword(), request.isAsync()));
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@@ -458,6 +457,7 @@ public class RepositoryExportResource {
|
||||
return Response.status(204).build();
|
||||
} else {
|
||||
StreamingOutput output = os -> fullScmRepositoryExporter.export(repository, os, password);
|
||||
exportService.setExportFinished(repository);
|
||||
|
||||
return Response
|
||||
.ok(output, "application/x-gzip")
|
||||
@@ -485,6 +485,7 @@ public class RepositoryExportResource {
|
||||
return createResponse(repository, fileExtension, compressed, output);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
notificationHandler.handleFailedExport(repository);
|
||||
throw new ExportFailedException(entity(repository).build(), "repository export failed", e);
|
||||
}
|
||||
}
|
||||
@@ -533,7 +534,7 @@ public class RepositoryExportResource {
|
||||
return executorService;
|
||||
}
|
||||
|
||||
|
||||
@SuppressWarnings("java:S110") // is ok for this type of exceptions
|
||||
private static class WrongTypeException extends BadRequestException {
|
||||
|
||||
private static final String CODE = "4hSNNTBiu1";
|
||||
|
||||
@@ -194,10 +194,12 @@ class ResourceLinks {
|
||||
|
||||
static class MeLinks {
|
||||
private final LinkBuilder meLinkBuilder;
|
||||
private final LinkBuilder notificationLinkBuilder;
|
||||
private UserLinks userLinks;
|
||||
|
||||
MeLinks(ScmPathInfo pathInfo, UserLinks user) {
|
||||
meLinkBuilder = new LinkBuilder(pathInfo, MeResource.class);
|
||||
notificationLinkBuilder = new LinkBuilder(pathInfo, MeResource.class, NotificationResource.class);
|
||||
userLinks = user;
|
||||
}
|
||||
|
||||
@@ -216,6 +218,10 @@ class ResourceLinks {
|
||||
public String passwordChange() {
|
||||
return meLinkBuilder.method("changePassword").parameters().href();
|
||||
}
|
||||
|
||||
String notifications() {
|
||||
return notificationLinkBuilder.method("notifications").parameters().method("getAll").parameters().href();
|
||||
}
|
||||
}
|
||||
|
||||
public ApiKeyCollectionLinks apiKeyCollection() {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package sonia.scm.importexport;
|
||||
|
||||
import sonia.scm.notifications.Notification;
|
||||
import sonia.scm.notifications.NotificationSender;
|
||||
import sonia.scm.notifications.Type;
|
||||
import sonia.scm.repository.Repository;
|
||||
|
||||
import javax.inject.Inject;
|
||||
|
||||
public class ExportNotificationHandler {
|
||||
|
||||
private final NotificationSender notificationSender;
|
||||
|
||||
@Inject
|
||||
public ExportNotificationHandler(NotificationSender notificationSender) {
|
||||
this.notificationSender = notificationSender;
|
||||
}
|
||||
|
||||
public void handleFailedExport(Repository repository) {
|
||||
notificationSender.send(getExportFailedNotification(repository));
|
||||
}
|
||||
|
||||
public void handleSuccessfulExport(Repository repository) {
|
||||
notificationSender.send(getExportSuccessfulNotification(repository));
|
||||
}
|
||||
|
||||
private Notification getExportFailedNotification(Repository repository) {
|
||||
return new Notification(Type.ERROR, "/repo/" + repository.getNamespaceAndName() + "/settings/general", "exportFailed");
|
||||
}
|
||||
|
||||
private Notification getExportSuccessfulNotification(Repository repository) {
|
||||
return new Notification(Type.SUCCESS, "/repo/" + repository.getNamespaceAndName() + "/settings/general", "exportFinished");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -55,12 +55,14 @@ public class ExportService {
|
||||
private final BlobStoreFactory blobStoreFactory;
|
||||
private final DataStoreFactory dataStoreFactory;
|
||||
private final ExportFileExtensionResolver fileExtensionResolver;
|
||||
private final ExportNotificationHandler notificationHandler;
|
||||
|
||||
@Inject
|
||||
public ExportService(BlobStoreFactory blobStoreFactory, DataStoreFactory dataStoreFactory, ExportFileExtensionResolver fileExtensionResolver) {
|
||||
public ExportService(BlobStoreFactory blobStoreFactory, DataStoreFactory dataStoreFactory, ExportFileExtensionResolver fileExtensionResolver, ExportNotificationHandler notificationHandler) {
|
||||
this.blobStoreFactory = blobStoreFactory;
|
||||
this.dataStoreFactory = dataStoreFactory;
|
||||
this.fileExtensionResolver = fileExtensionResolver;
|
||||
this.notificationHandler = notificationHandler;
|
||||
}
|
||||
|
||||
public OutputStream store(Repository repository, boolean withMetadata, boolean compressed, boolean encrypted) {
|
||||
@@ -69,6 +71,7 @@ public class ExportService {
|
||||
try {
|
||||
return storeNewBlob(repository.getId()).getOutputStream();
|
||||
} catch (IOException e) {
|
||||
notificationHandler.handleFailedExport(repository);
|
||||
throw new ExportFailedException(
|
||||
entity(repository).build(),
|
||||
"Could not store repository export to blob file",
|
||||
@@ -120,6 +123,7 @@ public class ExportService {
|
||||
RepositoryExportInformation info = dataStore.get(repository.getId());
|
||||
info.setStatus(ExportStatus.FINISHED);
|
||||
dataStore.put(repository.getId(), info);
|
||||
notificationHandler.handleSuccessfulExport(repository);
|
||||
}
|
||||
|
||||
public boolean isExporting(Repository repository) {
|
||||
|
||||
@@ -59,6 +59,7 @@ public class FullScmRepositoryExporter {
|
||||
private final WorkdirProvider workdirProvider;
|
||||
private final RepositoryExportingCheck repositoryExportingCheck;
|
||||
private final RepositoryImportExportEncryption repositoryImportExportEncryption;
|
||||
private final ExportNotificationHandler notificationHandler;
|
||||
|
||||
@Inject
|
||||
public FullScmRepositoryExporter(EnvironmentInformationXmlGenerator environmentGenerator,
|
||||
@@ -67,7 +68,7 @@ public class FullScmRepositoryExporter {
|
||||
TarArchiveRepositoryStoreExporter storeExporter,
|
||||
WorkdirProvider workdirProvider,
|
||||
RepositoryExportingCheck repositoryExportingCheck,
|
||||
RepositoryImportExportEncryption repositoryImportExportEncryption) {
|
||||
RepositoryImportExportEncryption repositoryImportExportEncryption, ExportNotificationHandler notificationHandler) {
|
||||
this.environmentGenerator = environmentGenerator;
|
||||
this.metadataGenerator = metadataGenerator;
|
||||
this.serviceFactory = serviceFactory;
|
||||
@@ -75,13 +76,19 @@ public class FullScmRepositoryExporter {
|
||||
this.workdirProvider = workdirProvider;
|
||||
this.repositoryExportingCheck = repositoryExportingCheck;
|
||||
this.repositoryImportExportEncryption = repositoryImportExportEncryption;
|
||||
this.notificationHandler = notificationHandler;
|
||||
}
|
||||
|
||||
public void export(Repository repository, OutputStream outputStream, String password) {
|
||||
repositoryExportingCheck.withExportingLock(repository, () -> {
|
||||
exportInLock(repository, outputStream, password);
|
||||
return null;
|
||||
});
|
||||
try {
|
||||
repositoryExportingCheck.withExportingLock(repository, () -> {
|
||||
exportInLock(repository, outputStream, password);
|
||||
return null;
|
||||
});
|
||||
} catch (ExportFailedException ex) {
|
||||
notificationHandler.handleFailedExport(repository);
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
|
||||
private void exportInLock(Repository repository, OutputStream outputStream, String password) {
|
||||
|
||||
@@ -63,6 +63,8 @@ import sonia.scm.net.ahc.ContentTransformer;
|
||||
import sonia.scm.net.ahc.DefaultAdvancedHttpClient;
|
||||
import sonia.scm.net.ahc.JsonContentTransformer;
|
||||
import sonia.scm.net.ahc.XmlContentTransformer;
|
||||
import sonia.scm.notifications.DefaultNotificationSender;
|
||||
import sonia.scm.notifications.NotificationSender;
|
||||
import sonia.scm.plugin.DefaultPluginManager;
|
||||
import sonia.scm.plugin.PluginLoader;
|
||||
import sonia.scm.plugin.PluginManager;
|
||||
@@ -263,6 +265,8 @@ class ScmServletModule extends ServletModule {
|
||||
bind(PermissionProvider.class).to(RepositoryPermissionProvider.class);
|
||||
|
||||
bind(HealthCheckService.class).to(DefaultHealthCheckService.class);
|
||||
|
||||
bind(NotificationSender.class).to(DefaultNotificationSender.class);
|
||||
}
|
||||
|
||||
private <T> void bind(Class<T> clazz, Class<? extends T> defaultImplementation) {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package sonia.scm.notifications;
|
||||
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import sonia.scm.sse.Channel;
|
||||
import sonia.scm.sse.ChannelRegistry;
|
||||
import sonia.scm.sse.Message;
|
||||
|
||||
import javax.inject.Inject;
|
||||
|
||||
public class DefaultNotificationSender implements NotificationSender {
|
||||
|
||||
@VisibleForTesting
|
||||
static final String MESSAGE_NAME = "notification";
|
||||
|
||||
private final NotificationStore store;
|
||||
private final ChannelRegistry channelRegistry;
|
||||
|
||||
@Inject
|
||||
public DefaultNotificationSender(NotificationStore store, ChannelRegistry channelRegistry) {
|
||||
this.store = store;
|
||||
this.channelRegistry = channelRegistry;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(Notification notification, String recipient) {
|
||||
store.add(notification, recipient);
|
||||
Channel channel = channelRegistry.channel(new NotificationChannelId(recipient));
|
||||
channel.broadcast(message(notification));
|
||||
}
|
||||
|
||||
private Message message(Notification notification) {
|
||||
return new Message(MESSAGE_NAME, Notification.class, notification);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package sonia.scm.notifications;
|
||||
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.apache.shiro.SecurityUtils;
|
||||
|
||||
@EqualsAndHashCode
|
||||
public class NotificationChannelId {
|
||||
|
||||
private final String username;
|
||||
|
||||
public NotificationChannelId(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public static NotificationChannelId current() {
|
||||
return new NotificationChannelId(
|
||||
SecurityUtils.getSubject().getPrincipal().toString()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package sonia.scm.notifications;
|
||||
|
||||
import com.github.legman.Subscribe;
|
||||
import com.google.common.util.concurrent.Striped;
|
||||
import lombok.Data;
|
||||
import org.apache.shiro.SecurityUtils;
|
||||
import sonia.scm.HandlerEventType;
|
||||
import sonia.scm.security.KeyGenerator;
|
||||
import sonia.scm.store.DataStore;
|
||||
import sonia.scm.store.DataStoreFactory;
|
||||
import sonia.scm.user.UserEvent;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Singleton;
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
import javax.xml.bind.annotation.XmlElement;
|
||||
import javax.xml.bind.annotation.XmlElementWrapper;
|
||||
import javax.xml.bind.annotation.XmlRootElement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.locks.Lock;
|
||||
import java.util.concurrent.locks.ReadWriteLock;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Singleton
|
||||
@SuppressWarnings("UnstableApiUsage") // striped is still marked as beta
|
||||
public class NotificationStore {
|
||||
|
||||
private static final String NAME = "notifications";
|
||||
private final DataStore<StoredNotifications> store;
|
||||
private final KeyGenerator keyGenerator;
|
||||
private final Striped<ReadWriteLock> locks = Striped.readWriteLock(10);
|
||||
|
||||
@Inject
|
||||
public NotificationStore(DataStoreFactory dataStoreFactory, KeyGenerator keyGenerator) {
|
||||
this.store = dataStoreFactory.withType(StoredNotifications.class)
|
||||
.withName(NAME)
|
||||
.build();
|
||||
this.keyGenerator = keyGenerator;
|
||||
}
|
||||
|
||||
public String add(Notification notification, String username) {
|
||||
Lock lock = locks.get(username).writeLock();
|
||||
try {
|
||||
lock.lock();
|
||||
StoredNotifications notifications = get(username);
|
||||
String id = keyGenerator.createKey();
|
||||
notifications.getEntries().add(new StoredNotification(id, notification));
|
||||
store.put(username, notifications);
|
||||
return id;
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private StoredNotifications get(String username) {
|
||||
return store.getOptional(username).orElse(new StoredNotifications());
|
||||
}
|
||||
|
||||
public List<StoredNotification> getAll() {
|
||||
String username = getCurrentUsername();
|
||||
Lock lock = locks.get(username).readLock();
|
||||
try {
|
||||
lock.lock();
|
||||
StoredNotifications notifications = get(username);
|
||||
return Collections.unmodifiableList(notifications.getEntries());
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public void remove(String id) {
|
||||
String username = getCurrentUsername();
|
||||
Lock lock = locks.get(username).writeLock();
|
||||
try {
|
||||
lock.lock();
|
||||
StoredNotifications notifications = get(username);
|
||||
List<StoredNotification> entries = notifications.getEntries()
|
||||
.stream()
|
||||
.filter(n -> !id.equals(n.id))
|
||||
.collect(Collectors.toList());
|
||||
notifications.setEntries(entries);
|
||||
store.put(username, notifications);
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
String username = getCurrentUsername();
|
||||
Lock lock = locks.get(username).writeLock();
|
||||
try {
|
||||
lock.lock();
|
||||
StoredNotifications notifications = get(username);
|
||||
notifications.getEntries().clear();
|
||||
store.put(username, notifications);
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private String getCurrentUsername() {
|
||||
return SecurityUtils.getSubject().getPrincipal().toString();
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void handle(UserEvent event) {
|
||||
if (event.getEventType() == HandlerEventType.DELETE) {
|
||||
String username = event.getItem().getName();
|
||||
Lock lock = locks.get(username).writeLock();
|
||||
try {
|
||||
lock.lock();
|
||||
store.remove(username);
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
@XmlRootElement
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
static class StoredNotifications {
|
||||
@XmlElement(name = "notification")
|
||||
@XmlElementWrapper(name = "notifications")
|
||||
private List<StoredNotification> entries;
|
||||
|
||||
public List<StoredNotification> getEntries() {
|
||||
if (entries == null) {
|
||||
entries = new ArrayList<>();
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package sonia.scm.notifications;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import sonia.scm.xml.XmlInstantAdapter;
|
||||
|
||||
import javax.xml.bind.annotation.XmlAccessType;
|
||||
import javax.xml.bind.annotation.XmlAccessorType;
|
||||
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
|
||||
import java.time.Instant;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@XmlAccessorType(XmlAccessType.FIELD)
|
||||
public class StoredNotification {
|
||||
|
||||
String id;
|
||||
Type type;
|
||||
String link;
|
||||
String message;
|
||||
@XmlJavaTypeAdapter(XmlInstantAdapter.class)
|
||||
Instant createdAt;
|
||||
|
||||
StoredNotification(String id, Notification notification) {
|
||||
this.id = id;
|
||||
this.createdAt = notification.getCreatedAt();
|
||||
this.type = notification.getType();
|
||||
this.link = notification.getLink();
|
||||
this.message = notification.getMessage();
|
||||
}
|
||||
}
|
||||
@@ -424,5 +424,9 @@
|
||||
"CustomNamespaceStrategy": "Benutzerdefiniert",
|
||||
"CurrentYearNamespaceStrategy": "Aktuelles Jahr",
|
||||
"RepositoryTypeNamespaceStrategy": "Repository Typ"
|
||||
},
|
||||
"notifications": {
|
||||
"exportFinished": "Der Repository Export wurde abgeschlossen.",
|
||||
"exportFailed": "Der Repository Export ist fehlgeschlagen. Versuchen Sie es erneut oder wenden Sie sich an einen Administrator."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,5 +368,9 @@
|
||||
"CustomNamespaceStrategy": "Custom",
|
||||
"CurrentYearNamespaceStrategy": "Current year",
|
||||
"RepositoryTypeNamespaceStrategy": "Repository type"
|
||||
},
|
||||
"notifications": {
|
||||
"exportFinished": "The repository export has been finished.",
|
||||
"exportFailed": "The repository export has failed. Try it again or contact your administrator."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,4 +260,13 @@ class MeDtoFactoryTest {
|
||||
MeDto dto = meDtoFactory.create();
|
||||
assertThat(dto.getLinks().getLinkBy("apiKeys")).isNotPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAppendNotificationsLink() {
|
||||
User user = UserTestData.createTrillian();
|
||||
prepareSubject(user);
|
||||
|
||||
MeDto dto = meDtoFactory.create();
|
||||
assertThat(dto.getLinks().getLinkBy("notifications").get().getHref()).isEqualTo("https://scm.hitchhiker.com/scm/v2/me/notifications");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,8 @@ public class MeResourceTest {
|
||||
private PasswordService passwordService;
|
||||
private User originalUser;
|
||||
|
||||
@Mock
|
||||
private NotificationResource notificationResource;
|
||||
|
||||
@Before
|
||||
public void prepareEnvironment() {
|
||||
@@ -127,7 +129,7 @@ public class MeResourceTest {
|
||||
when(userManager.getDefaultType()).thenReturn("xml");
|
||||
ApiKeyCollectionToDtoMapper apiKeyCollectionMapper = new ApiKeyCollectionToDtoMapper(apiKeyMapper, resourceLinks);
|
||||
ApiKeyResource apiKeyResource = new ApiKeyResource(apiKeyService, apiKeyCollectionMapper, apiKeyMapper, resourceLinks);
|
||||
MeResource meResource = new MeResource(meDtoFactory, userManager, passwordService, of(apiKeyResource));
|
||||
MeResource meResource = new MeResource(meDtoFactory, userManager, passwordService, of(apiKeyResource), of(notificationResource));
|
||||
when(uriInfo.getApiRestUri()).thenReturn(URI.create("/"));
|
||||
when(scmPathInfoStore.get()).thenReturn(uriInfo);
|
||||
dispatcher.addSingletonResource(meResource);
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package sonia.scm.api.v2.resources;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.jboss.resteasy.mock.MockHttpRequest;
|
||||
import org.jboss.resteasy.mock.MockHttpResponse;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import sonia.scm.notifications.Notification;
|
||||
import sonia.scm.notifications.NotificationStore;
|
||||
import sonia.scm.notifications.StoredNotification;
|
||||
import sonia.scm.notifications.Type;
|
||||
import sonia.scm.sse.ChannelRegistry;
|
||||
import sonia.scm.web.RestDispatcher;
|
||||
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.ws.rs.Path;
|
||||
import java.io.IOException;
|
||||
import java.net.URISyntaxException;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class NotificationResourceTest {
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
@Mock
|
||||
private NotificationStore store;
|
||||
|
||||
@Mock
|
||||
private ChannelRegistry channelRegistry;
|
||||
|
||||
private RestDispatcher dispatcher;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
dispatcher = new RestDispatcher();
|
||||
dispatcher.addSingletonResource(
|
||||
new TestingRootResource(
|
||||
new NotificationResource(store, channelRegistry)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnAllNotifications() throws IOException, URISyntaxException {
|
||||
notifications("One", "Two", "Three");
|
||||
|
||||
MockHttpRequest request = MockHttpRequest.get("/api/v2/notifications");
|
||||
MockHttpResponse response = invoke(request);
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
|
||||
JsonNode node = mapper.readTree(response.getContentAsString());
|
||||
JsonNode notificationNodes = node.get("_embedded").get("notifications");
|
||||
assertThat(notificationNodes.get(0).get("message").asText()).isEqualTo("One");
|
||||
assertThat(notificationNodes.get(1).get("message").asText()).isEqualTo("Two");
|
||||
assertThat(notificationNodes.get(2).get("message").asText()).isEqualTo("Three");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnDismissLink() throws IOException, URISyntaxException {
|
||||
String id = notifications("One").get(0).getId();
|
||||
|
||||
MockHttpRequest request = MockHttpRequest.get("/api/v2/notifications");
|
||||
MockHttpResponse response = invoke(request);
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
|
||||
|
||||
JsonNode node = mapper.readTree(response.getContentAsString());
|
||||
String dismissHref = node.get("_embedded")
|
||||
.get("notifications")
|
||||
.get(0)
|
||||
.get("_links")
|
||||
.get("dismiss")
|
||||
.get("href")
|
||||
.asText();
|
||||
|
||||
assertThat(dismissHref).isEqualTo("/api/v2/notifications/" + id);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnCollectionLinks() throws IOException, URISyntaxException {
|
||||
notifications();
|
||||
|
||||
MockHttpRequest request = MockHttpRequest.get("/api/v2/notifications");
|
||||
MockHttpResponse response = invoke(request);
|
||||
|
||||
JsonNode node = mapper.readTree(response.getContentAsString());
|
||||
JsonNode links = node.get("_links");
|
||||
|
||||
assertThat(links.get("self").get("href").asText()).isEqualTo("/api/v2/notifications");
|
||||
assertThat(links.get("clear").get("href").asText()).isEqualTo("/api/v2/notifications");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRemoveNotification() throws URISyntaxException {
|
||||
MockHttpRequest request = MockHttpRequest.delete("/api/v2/notifications/abc42");
|
||||
MockHttpResponse response = invoke(request);
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_NO_CONTENT);
|
||||
|
||||
verify(store).remove("abc42");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldClear() throws URISyntaxException {
|
||||
MockHttpRequest request = MockHttpRequest.delete("/api/v2/notifications");
|
||||
MockHttpResponse response = invoke(request);
|
||||
|
||||
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_NO_CONTENT);
|
||||
verify(store).clear();
|
||||
}
|
||||
|
||||
private MockHttpResponse invoke(MockHttpRequest request) {
|
||||
MockHttpResponse response = new MockHttpResponse();
|
||||
dispatcher.invoke(request, response);
|
||||
return response;
|
||||
}
|
||||
|
||||
private List<StoredNotification> notifications(String... messages) {
|
||||
List<StoredNotification> notifications = Arrays.stream(messages)
|
||||
.map(this::notification)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
when(store.getAll()).thenReturn(notifications);
|
||||
return notifications;
|
||||
}
|
||||
|
||||
private StoredNotification notification(String m) {
|
||||
return new StoredNotification(UUID.randomUUID().toString(), Type.INFO, "/notify", m, Instant.now());
|
||||
}
|
||||
|
||||
@Path("/api/v2")
|
||||
public static class TestingRootResource {
|
||||
|
||||
private final NotificationResource resource;
|
||||
|
||||
public TestingRootResource(NotificationResource resource) {
|
||||
this.resource = resource;
|
||||
}
|
||||
|
||||
@Path("notifications")
|
||||
public NotificationResource notifications() {
|
||||
return resource;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -46,6 +46,7 @@ import sonia.scm.NotFoundException;
|
||||
import sonia.scm.PageResult;
|
||||
import sonia.scm.config.ScmConfiguration;
|
||||
import sonia.scm.importexport.ExportFileExtensionResolver;
|
||||
import sonia.scm.importexport.ExportNotificationHandler;
|
||||
import sonia.scm.importexport.ExportService;
|
||||
import sonia.scm.importexport.ExportStatus;
|
||||
import sonia.scm.importexport.FromBundleImporter;
|
||||
@@ -160,6 +161,8 @@ public class RepositoryRootResourceTest extends RepositoryTestBase {
|
||||
private ExportService exportService;
|
||||
@Mock
|
||||
private HealthCheckService healthCheckService;
|
||||
@Mock
|
||||
private ExportNotificationHandler notificationHandler;
|
||||
|
||||
@Captor
|
||||
private ArgumentCaptor<Predicate<Repository>> filterCaptor;
|
||||
@@ -182,7 +185,7 @@ public class RepositoryRootResourceTest extends RepositoryTestBase {
|
||||
RepositoryCollectionToDtoMapper repositoryCollectionToDtoMapper = new RepositoryCollectionToDtoMapper(repositoryToDtoMapper, resourceLinks);
|
||||
super.repositoryCollectionResource = new RepositoryCollectionResource(repositoryManager, repositoryCollectionToDtoMapper, dtoToRepositoryMapper, resourceLinks, repositoryInitializer);
|
||||
super.repositoryImportResource = new RepositoryImportResource(dtoToRepositoryMapper, resourceLinks, fullScmRepositoryImporter, new RepositoryImportDtoToRepositoryImportParametersMapperImpl(), repositoryImportExportEncryption, fromUrlImporter, fromBundleImporter, importLoggerFactory);
|
||||
super.repositoryExportResource = new RepositoryExportResource(repositoryManager, serviceFactory, fullScmRepositoryExporter, repositoryImportExportEncryption, exportService, exportInformationToDtoMapper, fileExtensionResolver, resourceLinks, new SimpleMeterRegistry());
|
||||
super.repositoryExportResource = new RepositoryExportResource(repositoryManager, serviceFactory, fullScmRepositoryExporter, repositoryImportExportEncryption, exportService, exportInformationToDtoMapper, fileExtensionResolver, resourceLinks, new SimpleMeterRegistry(), notificationHandler);
|
||||
dispatcher.addSingletonResource(getRepositoryRootResource());
|
||||
when(serviceFactory.create(any(Repository.class))).thenReturn(service);
|
||||
doReturn(ImmutableSet.of(new CustomNamespaceStrategy()).iterator()).when(strategies).iterator();
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package sonia.scm.importexport;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import sonia.scm.notifications.NotificationSender;
|
||||
import sonia.scm.notifications.Type;
|
||||
import sonia.scm.repository.RepositoryTestData;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ExportNotificationHandlerTest {
|
||||
|
||||
@Mock
|
||||
private NotificationSender sender;
|
||||
|
||||
@InjectMocks
|
||||
private ExportNotificationHandler handler;
|
||||
|
||||
@Test
|
||||
void shouldSendFailedNotification() {
|
||||
handler.handleFailedExport(RepositoryTestData.create42Puzzle());
|
||||
|
||||
verify(sender).send(argThat(notification -> {
|
||||
assertThat(notification.getType()).isEqualTo(Type.ERROR);
|
||||
assertThat(notification.getLink()).isEqualTo("/repo/hitchhiker/42Puzzle/settings/general");
|
||||
assertThat(notification.getMessage()).isEqualTo("exportFailed");
|
||||
return true;
|
||||
}));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSendSuccessfulNotification() {
|
||||
handler.handleSuccessfulExport(RepositoryTestData.create42Puzzle());
|
||||
|
||||
verify(sender).send(argThat(notification -> {
|
||||
assertThat(notification.getType()).isEqualTo(Type.SUCCESS);
|
||||
assertThat(notification.getLink()).isEqualTo("/repo/hitchhiker/42Puzzle/settings/general");
|
||||
assertThat(notification.getMessage()).isEqualTo("exportFinished");
|
||||
return true;
|
||||
}));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import sonia.scm.NotFoundException;
|
||||
import sonia.scm.notifications.Type;
|
||||
import sonia.scm.repository.Repository;
|
||||
import sonia.scm.repository.RepositoryTestData;
|
||||
import sonia.scm.store.Blob;
|
||||
@@ -55,9 +56,11 @@ import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.Mockito.doNothing;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static sonia.scm.importexport.ExportService.STORE_NAME;
|
||||
|
||||
@@ -75,6 +78,9 @@ class ExportServiceTest {
|
||||
@Mock
|
||||
private ExportFileExtensionResolver resolver;
|
||||
|
||||
@Mock
|
||||
private ExportNotificationHandler notificationHandler;
|
||||
|
||||
private BlobStore blobStore;
|
||||
private DataStore<RepositoryExportInformation> dataStore;
|
||||
|
||||
@@ -136,6 +142,7 @@ class ExportServiceTest {
|
||||
|
||||
exportService.setExportFinished(REPOSITORY);
|
||||
assertThat(exportService.isExporting(REPOSITORY)).isFalse();
|
||||
verify(notificationHandler).handleSuccessfulExport(REPOSITORY);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package sonia.scm.notifications;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import sonia.scm.sse.Channel;
|
||||
import sonia.scm.sse.ChannelRegistry;
|
||||
import sonia.scm.sse.Message;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DefaultNotificationSenderTest {
|
||||
|
||||
@Mock
|
||||
private NotificationStore store;
|
||||
|
||||
@Mock
|
||||
private ChannelRegistry channelRegistry;
|
||||
|
||||
@InjectMocks
|
||||
private DefaultNotificationSender sender;
|
||||
|
||||
@Mock
|
||||
private Channel channel;
|
||||
|
||||
@Test
|
||||
void shouldDelegateToStore() {
|
||||
when(channelRegistry.channel(any())).thenReturn(channel);
|
||||
|
||||
Notification notification = new Notification(Type.ERROR, "/fail", "Everything has failed");
|
||||
|
||||
sender.send(notification, "trillian");
|
||||
|
||||
verify(store).add(notification, "trillian");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSendToChannel() {
|
||||
NotificationChannelId channelId = new NotificationChannelId("trillian");
|
||||
when(channelRegistry.channel(channelId)).thenReturn(channel);
|
||||
|
||||
Notification notification = new Notification(Type.WARNING, "/warn", "Everything looks strange");
|
||||
|
||||
sender.send(notification, "trillian");
|
||||
ArgumentCaptor<Message> messageCaptor = ArgumentCaptor.forClass(Message.class);
|
||||
verify(channel).broadcast(messageCaptor.capture());
|
||||
|
||||
Message message = messageCaptor.getValue();
|
||||
assertThat(message.getName()).isEqualTo(DefaultNotificationSender.MESSAGE_NAME);
|
||||
assertThat(message.getType()).isEqualTo(Notification.class);
|
||||
assertThat(message.getData()).isEqualTo(notification);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
package sonia.scm.notifications;
|
||||
|
||||
import org.github.sdorra.jse.ShiroExtension;
|
||||
import org.github.sdorra.jse.SubjectAware;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.EnumSource;
|
||||
import sonia.scm.HandlerEventType;
|
||||
import sonia.scm.security.KeyGenerator;
|
||||
import sonia.scm.security.UUIDKeyGenerator;
|
||||
import sonia.scm.store.InMemoryByteDataStoreFactory;
|
||||
import sonia.scm.user.UserEvent;
|
||||
import sonia.scm.user.UserTestData;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@ExtendWith(ShiroExtension.class)
|
||||
class NotificationStoreTest {
|
||||
|
||||
private NotificationStore store;
|
||||
private final KeyGenerator keyGenerator = new UUIDKeyGenerator();
|
||||
|
||||
private AtomicInteger counter;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
counter = new AtomicInteger();
|
||||
store = new NotificationStore(new InMemoryByteDataStoreFactory(), keyGenerator);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SubjectAware("trillian")
|
||||
void shouldAddNotification() {
|
||||
Notification notification = notification();
|
||||
|
||||
store.add(notification, "trillian");
|
||||
|
||||
containsMessage(notification);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SubjectAware("trillian")
|
||||
void shouldReturnId() {
|
||||
Notification notification = notification();
|
||||
|
||||
String id = store.add(notification, "trillian");
|
||||
assertThat(id).isNotNull().isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@SubjectAware("trillian")
|
||||
void shouldAssignId() {
|
||||
Notification notification = notification();
|
||||
|
||||
store.add(notification, "trillian");
|
||||
|
||||
StoredNotification storedNotification = store.getAll().get(0);
|
||||
assertThat(storedNotification.getId()).isNotNull().isNotEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@SubjectAware("trillian")
|
||||
void shouldCopyProperties() {
|
||||
Notification notification = notification();
|
||||
|
||||
store.add(notification, "trillian");
|
||||
|
||||
StoredNotification storedNotification = store.getAll().get(0);
|
||||
assertThat(storedNotification)
|
||||
.usingRecursiveComparison()
|
||||
.ignoringFields("id")
|
||||
.isEqualTo(notification);
|
||||
}
|
||||
|
||||
private void containsMessage(Notification... notifications) {
|
||||
String[] messages = Arrays.stream(notifications).map(Notification::getMessage).toArray(String[]::new);
|
||||
Stream<String> storedMessages = store.getAll().stream().map(StoredNotification::getMessage);
|
||||
assertThat(storedMessages).containsOnly(messages);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SubjectAware("trillian")
|
||||
void shouldOnlyReturnNotificationsForPrincipal() {
|
||||
Notification one = notification();
|
||||
Notification two = notification();
|
||||
Notification three = notification();
|
||||
|
||||
store.add(one, "trillian");
|
||||
store.add(two, "dent");
|
||||
store.add(three, "trillian");
|
||||
|
||||
containsMessage(one, three);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SubjectAware("slarti")
|
||||
void shouldClearOnlyPrincipalNotifications() {
|
||||
Notification one = notification();
|
||||
Notification two = notification();
|
||||
Notification three = notification();
|
||||
|
||||
store.add(one, "slarti");
|
||||
store.add(two, "slarti");
|
||||
store.add(three, "slarti");
|
||||
|
||||
store.clear();
|
||||
assertThat(store.getAll()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@SubjectAware("slarti")
|
||||
void shouldRemoveNotificationWithId() {
|
||||
Notification one = notification();
|
||||
Notification two = notification();
|
||||
|
||||
String id = store.add(one, "slarti");
|
||||
store.add(two, "slarti");
|
||||
|
||||
store.remove(id);
|
||||
|
||||
containsMessage(two);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SubjectAware("slarti")
|
||||
void shouldRemoveEntryIfUserIsDeleted() {
|
||||
store.add(notification(), "slarti");
|
||||
store.handle(new UserEvent(HandlerEventType.DELETE, UserTestData.createSlarti()));
|
||||
|
||||
assertThat(store.getAll()).isEmpty();
|
||||
}
|
||||
|
||||
@SubjectAware("slarti")
|
||||
@ParameterizedTest(name = "shouldIgnoreEvent_{argumentsWithNames}")
|
||||
@EnumSource(value = HandlerEventType.class, mode = EnumSource.Mode.EXCLUDE, names = "DELETE")
|
||||
void shouldIgnoreNonDeleteEvents(HandlerEventType event) {
|
||||
store.add(notification(), "slarti");
|
||||
store.handle(new UserEvent(event, UserTestData.createSlarti()));
|
||||
|
||||
assertThat(store.getAll()).hasSize(1);
|
||||
}
|
||||
|
||||
private Notification notification() {
|
||||
return new Notification(Type.INFO, "/greeting", "Hello " + counter.incrementAndGet());
|
||||
}
|
||||
|
||||
}
|
||||