first working version with event reception in kanban
This commit is contained in:
@@ -1,21 +1,26 @@
|
|||||||
/* © SRSoftware 2025 */
|
/* © SRSoftware 2025 */
|
||||||
package de.srsoftware.umbrella.messagebus;
|
package de.srsoftware.umbrella.messagebus;
|
||||||
|
|
||||||
import de.srsoftware.umbrella.core.model.UmbrellaUser;
|
import static de.srsoftware.umbrella.core.Constants.USER;
|
||||||
|
|
||||||
public abstract class Event<Payload> {
|
import de.srsoftware.tools.Mappable;
|
||||||
|
import de.srsoftware.umbrella.core.model.UmbrellaUser;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
public abstract class Event<Payload extends Mappable> {
|
||||||
|
|
||||||
public enum EventType {
|
public enum EventType {
|
||||||
CREATE,
|
CREATE,
|
||||||
UPDATE,
|
UPDATE,
|
||||||
DELETE;
|
DELETE;
|
||||||
}
|
|
||||||
|
|
||||||
|
}
|
||||||
private UmbrellaUser initiator;
|
private UmbrellaUser initiator;
|
||||||
|
|
||||||
private String realm;
|
private String realm;
|
||||||
private Payload payload;
|
private Payload payload;
|
||||||
private EventType eventType;
|
private EventType eventType;
|
||||||
|
|
||||||
public Event(UmbrellaUser initiator, String realm, Payload payload, EventType type){
|
public Event(UmbrellaUser initiator, String realm, Payload payload, EventType type){
|
||||||
this.initiator = initiator;
|
this.initiator = initiator;
|
||||||
this.realm = realm;
|
this.realm = realm;
|
||||||
@@ -27,7 +32,21 @@ public abstract class Event<Payload> {
|
|||||||
return eventType.toString();
|
return eventType.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
public abstract String json();
|
public abstract boolean isIntendedFor(UmbrellaUser user);
|
||||||
|
|
||||||
|
public String json(){
|
||||||
|
Class<?> clazz = payload.getClass();
|
||||||
|
{ // get the highest superclass that is not object
|
||||||
|
Class<?> parent = clazz.getSuperclass();
|
||||||
|
|
||||||
|
while (parent != null && parent != Object.class) {
|
||||||
|
clazz = parent;
|
||||||
|
parent = clazz.getSuperclass();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var map = Map.of(USER,initiator.toMap(),clazz.getSimpleName().toLowerCase(),payload.toMap());
|
||||||
|
return new JSONObject(map).toString();
|
||||||
|
}
|
||||||
|
|
||||||
public Payload payload(){
|
public Payload payload(){
|
||||||
return payload;
|
return payload;
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import static de.srsoftware.umbrella.messagebus.MessageBus.messageBus;
|
|||||||
|
|
||||||
import java.net.InetSocketAddress;
|
import java.net.InetSocketAddress;
|
||||||
import java.util.LinkedList;
|
import java.util.LinkedList;
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
public class EventQueue extends LinkedList<Event> implements AutoCloseable, EventListener {
|
public class EventQueue extends LinkedList<Event> implements AutoCloseable, EventListener {
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package de.srsoftware.umbrella.messagebus;
|
|||||||
import static de.srsoftware.umbrella.core.Constants.*;
|
import static de.srsoftware.umbrella.core.Constants.*;
|
||||||
import static de.srsoftware.umbrella.core.ModuleRegistry.userService;
|
import static de.srsoftware.umbrella.core.ModuleRegistry.userService;
|
||||||
import static java.lang.System.Logger.Level.*;
|
import static java.lang.System.Logger.Level.*;
|
||||||
|
import static java.lang.Thread.sleep;
|
||||||
import static java.net.HttpURLConnection.HTTP_OK;
|
import static java.net.HttpURLConnection.HTTP_OK;
|
||||||
|
|
||||||
import com.sun.net.httpserver.HttpExchange;
|
import com.sun.net.httpserver.HttpExchange;
|
||||||
@@ -13,7 +14,6 @@ import de.srsoftware.tools.SessionToken;
|
|||||||
import de.srsoftware.umbrella.core.BaseHandler;
|
import de.srsoftware.umbrella.core.BaseHandler;
|
||||||
import de.srsoftware.umbrella.core.exceptions.UmbrellaException;
|
import de.srsoftware.umbrella.core.exceptions.UmbrellaException;
|
||||||
import de.srsoftware.umbrella.core.model.Token;
|
import de.srsoftware.umbrella.core.model.Token;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.PrintWriter;
|
import java.io.PrintWriter;
|
||||||
import java.net.InetSocketAddress;
|
import java.net.InetSocketAddress;
|
||||||
@@ -42,14 +42,16 @@ public class MessageApi extends BaseHandler{
|
|||||||
LOG.log(INFO,"{0} opened event stream.",addr);
|
LOG.log(INFO,"{0} opened event stream.",addr);
|
||||||
var counter = 0;
|
var counter = 0;
|
||||||
while (!stream.checkError()){
|
while (!stream.checkError()){
|
||||||
Thread.sleep(100);
|
sleep(100);
|
||||||
if (eventQueue.isEmpty()){
|
if (eventQueue.isEmpty()){
|
||||||
if (++counter > 300) counter = sendBeacon(addr,stream);
|
if (++counter > 300) counter = sendBeacon(addr,stream);
|
||||||
} else {
|
} else {
|
||||||
var elem = eventQueue.remove(0);
|
var event = eventQueue.removeFirst();
|
||||||
LOG.log(DEBUG,"sending event to {0}",addr);
|
//if (event.isIntendedFor(user.get())) {
|
||||||
sendEvent(stream,elem);
|
LOG.log(DEBUG, "sending event to {0}", addr);
|
||||||
|
sendEvent(stream, event);
|
||||||
counter = 0;
|
counter = 0;
|
||||||
|
//}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LOG.log(INFO,"{0} disconnected from event stream.",addr);
|
LOG.log(INFO,"{0} disconnected from event stream.",addr);
|
||||||
|
|||||||
@@ -5,15 +5,18 @@ import static de.srsoftware.umbrella.core.Constants.TASK;
|
|||||||
|
|
||||||
import de.srsoftware.umbrella.core.model.Task;
|
import de.srsoftware.umbrella.core.model.Task;
|
||||||
import de.srsoftware.umbrella.core.model.UmbrellaUser;
|
import de.srsoftware.umbrella.core.model.UmbrellaUser;
|
||||||
import org.json.JSONObject;
|
|
||||||
|
|
||||||
public class TaskEvent extends Event<Task>{
|
public class TaskEvent extends Event<Task>{
|
||||||
public TaskEvent(UmbrellaUser initiator, Task task, EventType type){
|
public TaskEvent(UmbrellaUser initiator, Task task, EventType type){
|
||||||
super(initiator,TASK, task, type);
|
super(initiator, TASK, task, type);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String json() {
|
public boolean isIntendedFor(UmbrellaUser user) {
|
||||||
return new JSONObject(payload().toMap()).toString();
|
for (var member : payload().members().values()){
|
||||||
|
if (member.user().equals(user)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@
|
|||||||
<span class="error">{@html messages.error}</span>
|
<span class="error">{@html messages.error}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if messages.warning}
|
{#if messages.warning}
|
||||||
<span class="error">{@html messages.warning}</span>
|
<span class="warn">{@html messages.warning}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<Route path="/" component={User} />
|
<Route path="/" component={User} />
|
||||||
<Route path="/bookmark" component={Bookmarks} />
|
<Route path="/bookmark" component={Bookmarks} />
|
||||||
@@ -130,7 +130,7 @@
|
|||||||
<span class="error">{@html messages.error}</span>
|
<span class="error">{@html messages.error}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if messages.warning}
|
{#if messages.warning}
|
||||||
<span class="error">{@html messages.warning}</span>
|
<span class="warn">{@html messages.warning}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<Route path="/user/reset/pw" component={ResetPw} />
|
<Route path="/user/reset/pw" component={ResetPw} />
|
||||||
<Route path="/oidc_callback" component={Callback} />
|
<Route path="/oidc_callback" component={Callback} />
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { useTinyRouter } from 'svelte-tiny-router';
|
import { useTinyRouter } from 'svelte-tiny-router';
|
||||||
|
|
||||||
import { api, target } from '../../urls.svelte.js';
|
import { api, eventStream, target } from '../../urls.svelte.js';
|
||||||
import { error, yikes } from '../../warn.svelte';
|
import { error, messages, yikes } from '../../warn.svelte';
|
||||||
import { t } from '../../translations.svelte.js';
|
import { t } from '../../translations.svelte.js';
|
||||||
import { user } from '../../user.svelte.js';
|
import { user } from '../../user.svelte.js';
|
||||||
|
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
let tasks = $state({});
|
let tasks = $state({});
|
||||||
let users = [];
|
let users = [];
|
||||||
let columns = $derived(project.allowed_states?Object.keys(project.allowed_states).length+1:1);
|
let columns = $derived(project.allowed_states?Object.keys(project.allowed_states).length+1:1);
|
||||||
|
let info = $state(null);
|
||||||
|
|
||||||
let stateList = {};
|
let stateList = {};
|
||||||
$effect(() => updateUrl(filter_input));
|
$effect(() => updateUrl(filter_input));
|
||||||
@@ -57,12 +58,6 @@
|
|||||||
return a.name.localeCompare(b.name);
|
return a.name.localeCompare(b.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
function connectToEventStream(){
|
|
||||||
eventSource = new EventSource(api('bus'));
|
|
||||||
eventSource.onmessage = (m) => { console.log(m); } // https://stackoverflow.com/questions/57650830/sse-onmessage-never-gets-called#comment104961222_59383080
|
|
||||||
eventSource.addEventListener('UPDATE', (event) => console.log(JSON.parse(event.data)) );
|
|
||||||
}
|
|
||||||
|
|
||||||
async function create(name,user_id,state){
|
async function create(name,user_id,state){
|
||||||
var url = api('task/add');
|
var url = api('task/add');
|
||||||
let task = {
|
let task = {
|
||||||
@@ -120,9 +115,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleUpdateEvent(evt){
|
||||||
|
let json = JSON.parse(evt.data);
|
||||||
|
if (json.task && json.user){
|
||||||
|
for (let uid in tasks){
|
||||||
|
if (!uid) continue;
|
||||||
|
for (let state in tasks[uid]) delete tasks[uid][state][json.task.id];
|
||||||
|
}
|
||||||
|
processTask(json.task);
|
||||||
|
if (json.user.id != user.id) {
|
||||||
|
info = t("user_updated_entity",{user:json.user.name,entity:json.task.name});
|
||||||
|
setTimeout(() => { info = null; },2500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
async function load(){
|
async function load(){
|
||||||
try {
|
try {
|
||||||
connectToEventStream();
|
eventSource = eventStream(handleUpdateEvent);
|
||||||
await loadProject();
|
await loadProject();
|
||||||
loadTasks({project_id:+id,parent_task_id:0});
|
loadTasks({project_id:+id,parent_task_id:0});
|
||||||
} catch (ignored) {}
|
} catch (ignored) {}
|
||||||
@@ -164,7 +175,15 @@
|
|||||||
var json = await resp.json();
|
var json = await resp.json();
|
||||||
for (var task_id of Object.keys(json)) {
|
for (var task_id of Object.keys(json)) {
|
||||||
let task = json[task_id];
|
let task = json[task_id];
|
||||||
if (task.no_index) continue;
|
processTask(task);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error(resp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function processTask(task){
|
||||||
|
if (task.no_index) return;
|
||||||
|
|
||||||
let state = +task.status;
|
let state = +task.status;
|
||||||
let owner = null;
|
let owner = null;
|
||||||
@@ -179,11 +198,7 @@
|
|||||||
task.assignee = assignee;
|
task.assignee = assignee;
|
||||||
if (!tasks[assignee]) tasks[assignee] = {};
|
if (!tasks[assignee]) tasks[assignee] = {};
|
||||||
if (!tasks[assignee][state]) tasks[assignee][state] = {};
|
if (!tasks[assignee][state]) tasks[assignee][state] = {};
|
||||||
tasks[assignee][state][task_id] = task;
|
tasks[assignee][state][task.id] = task;
|
||||||
}
|
|
||||||
} else {
|
|
||||||
error(resp);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hover(ev,user_id,state){
|
function hover(ev,user_id,state){
|
||||||
@@ -241,7 +256,9 @@
|
|||||||
{#if project}
|
{#if project}
|
||||||
<h1 onclick={ev => router.navigate(`/project/${project.id}/view`)}>{project.name}</h1>
|
<h1 onclick={ev => router.navigate(`/project/${project.id}/view`)}>{project.name}</h1>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if info}
|
||||||
|
<div class="info">{info}</div>
|
||||||
|
{/if}
|
||||||
{#if project}
|
{#if project}
|
||||||
<fieldset class="kanban description {descr?'active':''}" onclick={e => descr = !descr}>
|
<fieldset class="kanban description {descr?'active':''}" onclick={e => descr = !descr}>
|
||||||
<legend>{t('description')} – {t('expand_on_click')}</legend>
|
<legend>{t('description')} – {t('expand_on_click')}</legend>
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ export function drop(url){
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function eventStream(updateHandler){
|
||||||
|
const es = new EventSource(api('bus'), {withCredentials: true});
|
||||||
|
if (updateHandler) es.addEventListener('UPDATE', updateHandler);
|
||||||
|
return es;
|
||||||
|
}
|
||||||
|
|
||||||
export function patch(url,data){
|
export function patch(url,data){
|
||||||
return fetch(url,{
|
return fetch(url,{
|
||||||
credentials : 'include',
|
credentials : 'include',
|
||||||
|
|||||||
@@ -314,7 +314,8 @@ public class TaskModule extends BaseHandler implements TaskService {
|
|||||||
if (json.has(NEW_MEMBER) && json.get(NEW_MEMBER) instanceof Number num) addMember(task, num.longValue());
|
if (json.has(NEW_MEMBER) && json.get(NEW_MEMBER) instanceof Number num) addMember(task, num.longValue());
|
||||||
if (json.has(PARENT_TASK_ID) && json.get(PARENT_TASK_ID) instanceof Number ptid && newParentIsSubtask(task, ptid.longValue())) throw forbidden("Task must not be sub-task of itself.");
|
if (json.has(PARENT_TASK_ID) && json.get(PARENT_TASK_ID) instanceof Number ptid && newParentIsSubtask(task, ptid.longValue())) throw forbidden("Task must not be sub-task of itself.");
|
||||||
taskDb.save(task.patch(json));
|
taskDb.save(task.patch(json));
|
||||||
messageBus().dispatch(new TaskEvent(user,task, UPDATE));
|
var tagList = tagService().getTags(TASK, taskId, user);
|
||||||
|
messageBus().dispatch(new TaskEvent(user,new TaggedTask(task,tagList), UPDATE));
|
||||||
return sendContent(ex, task);
|
return sendContent(ex, task);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -343,6 +343,7 @@
|
|||||||
"user_list": "Benutzer-Liste",
|
"user_list": "Benutzer-Liste",
|
||||||
"user_module" : "Umbrella User-Verwaltung",
|
"user_module" : "Umbrella User-Verwaltung",
|
||||||
"users": "Benutzer",
|
"users": "Benutzer",
|
||||||
|
"user_updated_entity": "{user} hat \"{entity}\" bearbeitet",
|
||||||
|
|
||||||
"website": "Website",
|
"website": "Website",
|
||||||
"welcome" : "Willkommen, {0}",
|
"welcome" : "Willkommen, {0}",
|
||||||
|
|||||||
@@ -81,6 +81,11 @@ tr:hover .taglist .tag button {
|
|||||||
color: black;
|
color: black;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.info{
|
||||||
|
background: orange;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
|
||||||
.kanban .add_task,
|
.kanban .add_task,
|
||||||
.kanban .box,
|
.kanban .box,
|
||||||
.kanban .head,
|
.kanban .head,
|
||||||
|
|||||||
@@ -168,6 +168,15 @@ td, tr{
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
z-index: 200;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
.warn {
|
.warn {
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
|||||||
Reference in New Issue
Block a user