Updated UI + Indexing

This commit is contained in:
bskjon 2025-03-17 18:05:25 +01:00
parent b36f0c5e1d
commit 39d2bbe0b3
22 changed files with 1059 additions and 215 deletions

View File

@ -25,8 +25,21 @@ class UnattendedIndexing {
@Scheduled(fixedDelay = 60_000*60)
fun indexContent() {
logger.info { "Performing indexing of input root: ${SharedConfig.inputRoot.absolutePath}" }
val fileList = SharedConfig.inputRoot.walkTopDown().filter { it.isFile && it.isSupportedVideoFile() }.toList()
val allFiles = SharedConfig.incomingContent.flatMap { folder ->
logger.info { "Performing indexing of folder: ${folder.name}" }
folder.walkTopDown()
.filter { it.isFile && it.isSupportedVideoFile() }
.toMutableList()
}
val ignoredParents = allFiles
.asSequence()
.mapNotNull { it.parentFile }
.filter { parent -> parent.resolve(".ignore").exists() }
.toSet()
val fileList = allFiles
.filter { file -> file.parentFile !in ignoredParents }
fileList.forEach { file ->
withTransaction(eventDatabase.database) {
files.insertIgnore {

View File

@ -2,21 +2,27 @@ package no.iktdev.mediaprocessing.ui
import no.iktdev.mediaprocessing.shared.common.Defaults
import no.iktdev.mediaprocessing.shared.common.socket.SocketImplementation
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.web.client.RestTemplateBuilder
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory
import org.springframework.boot.web.server.WebServerFactoryCustomizer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Import
import org.springframework.core.io.Resource
import org.springframework.stereotype.Component
import org.springframework.stereotype.Service
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.client.RestTemplate
import org.springframework.web.method.HandlerTypePredicate
import org.springframework.web.servlet.config.annotation.*
import org.springframework.web.servlet.resource.PathResourceResolver
import org.springframework.web.socket.CloseStatus
import org.springframework.web.socket.TextMessage
import org.springframework.web.socket.WebSocketSession
import org.springframework.web.socket.handler.TextWebSocketHandler
import org.springframework.web.util.DefaultUriBuilderFactory
import org.springframework.web.util.UriTemplateHandler
import java.util.concurrent.ConcurrentHashMap
@Configuration
@ -84,6 +90,39 @@ class ApiCommunicationConfig {
@Configuration
class SocketImplemented: SocketImplementation() {
override var additionalOrigins: List<String> = UIEnv.wsAllowedOrigins.split(",")
}
@Service
class WebSocketMonitoringService() {
private val clients = ConcurrentHashMap.newKeySet<WebSocketSession>()
fun anyListening() = clients.isNotEmpty()
fun addClient(session: WebSocketSession) {
clients.add(session)
}
fun removeClient(session: WebSocketSession) {
clients.remove(session)
}
}
@Component
class WebSocketHandler(private val webSocketPollingService: WebSocketMonitoringService) : TextWebSocketHandler() {
// Kalles når en WebSocket-klient kobler til
override fun afterConnectionEstablished(session: WebSocketSession) {
webSocketPollingService.addClient(session) // Legg til klienten i service
}
// Kalles når en WebSocket-klient kobler fra
override fun afterConnectionClosed(session: WebSocketSession, status: CloseStatus) {
webSocketPollingService.removeClient(session) // Fjern klienten fra service
}
// Håndterer meldinger fra WebSocket-klientene hvis nødvendig
override fun handleTextMessage(session: WebSocketSession, message: TextMessage) {
// Håndter meldinger fra klienten
}
}
@Configuration

View File

@ -5,4 +5,5 @@ import java.io.File
object UIEnv {
val socketEncoder: String = System.getenv("EncoderWs")?.takeIf { it.isNotBlank() } ?: "ws://encoder:8080"
val coordinatorUrl: String = System.getenv("Coordinator")?.takeIf { it.isNotBlank() } ?: "http://coordinator"
val wsAllowedOrigins: String = System.getenv("AllowedOriginsWebsocket")?.takeIf { it.isNotBlank() } ?: ""
}

View File

@ -1,60 +0,0 @@
package no.iktdev.mediaprocessing.ui.socket
import mu.KotlinLogging
import no.iktdev.exfl.observable.ObservableList
import no.iktdev.exfl.observable.ObservableMap
import no.iktdev.exfl.observable.observableListOf
import no.iktdev.exfl.observable.observableMapOf
import no.iktdev.mediaprocessing.ui.dto.EventDataObject
import no.iktdev.mediaprocessing.ui.dto.EventSummary
import no.iktdev.mediaprocessing.ui.dto.SimpleEventDataObject
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.messaging.handler.annotation.MessageMapping
import org.springframework.messaging.simp.SimpMessagingTemplate
import org.springframework.stereotype.Controller
@Controller
class EventbasedTopic(
@Autowired private val template: SimpMessagingTemplate?
) {
private val log = KotlinLogging.logger {}
val summaryList: ObservableList<EventSummary> = observableListOf()
val memSimpleConvertedEventsMap: ObservableMap<String, SimpleEventDataObject> = observableMapOf()
val memActiveEventMap: ObservableMap<String, EventDataObject> = observableMapOf()
init {
memActiveEventMap.addListener(object : ObservableMap.Listener<String, EventDataObject> {
override fun onMapUpdated(map: Map<String, EventDataObject>) {
super.onMapUpdated(map)
log.info { "Sending data to WS" }
template?.convertAndSend("/topic/event/items", map.values.reversed())
if (template == null) {
log.error { "Template is null!" }
}
}
})
memSimpleConvertedEventsMap.addListener(object : ObservableMap.Listener<String, SimpleEventDataObject> {
override fun onMapUpdated(map: Map<String, SimpleEventDataObject>) {
super.onMapUpdated(map)
log.info { "Sending data to WS" }
template?.convertAndSend("/topic/event/flat", map.values.reversed())
if (template == null) {
log.error { "Template is null!" }
}
}
})
summaryList.addListener(object: ObservableList.Listener<EventSummary> {
override fun onListChanged(items: List<EventSummary>) {
super.onListChanged(items)
template?.convertAndSend("/topic/summary", items)
}
})
}
@MessageMapping("/items")
fun sendItems() {
template?.convertAndSend("/topic/event/items", memActiveEventMap.values.reversed())
template?.convertAndSend("/topic/event/flat", memSimpleConvertedEventsMap.values.reversed())
}
}

View File

@ -35,29 +35,6 @@ class ExplorerTopic(
}
}
@MessageMapping("/request/encode")
fun requestEncode(@Payload data: EventRequest) {
val req = coordinatorTemplate.postForEntity("/request/encode", data, String::class.java)
log.info { req }
}
@MessageMapping("/request/extract")
fun requestExtract(@Payload data: EventRequest) {
val req = coordinatorTemplate.postForEntity("/request/extract", data, String::class.java)
log.info { req }
}
@MessageMapping("/request/convert")
fun requestConvert(@Payload data: EventRequest) {
val req = coordinatorTemplate.postForEntity("/request/convert", data, String::class.java)
log.info { req }
}
@MessageMapping("/request/all")
fun requestAllAvailableActions(@Payload data: EventRequest) {
log.info { "Sending data to coordinator: ${Gson().toJson(data)}" }
val req = coordinatorTemplate.postForEntity("/request/all", data, String::class.java)
log.info { req }
}
}

View File

@ -0,0 +1,45 @@
package no.iktdev.mediaprocessing.ui.socket
import com.google.gson.Gson
import mu.KotlinLogging
import no.iktdev.mediaprocessing.shared.common.contract.dto.EventRequest
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.messaging.handler.annotation.MessageMapping
import org.springframework.messaging.handler.annotation.Payload
import org.springframework.messaging.simp.SimpMessagingTemplate
import org.springframework.stereotype.Controller
import org.springframework.web.client.RestTemplate
@Controller
class FileRequestTopic(
@Autowired private val template: SimpMessagingTemplate?,
@Autowired private val coordinatorTemplate: RestTemplate,
) {
val log = KotlinLogging.logger {}
@MessageMapping("/request/encode")
fun requestEncode(@Payload data: EventRequest) {
val req = coordinatorTemplate.postForEntity("/request/encode", data, String::class.java)
log.info { req }
}
@MessageMapping("/request/extract")
fun requestExtract(@Payload data: EventRequest) {
val req = coordinatorTemplate.postForEntity("/request/extract", data, String::class.java)
log.info { req }
}
@MessageMapping("/request/convert")
fun requestConvert(@Payload data: EventRequest) {
val req = coordinatorTemplate.postForEntity("/request/convert", data, String::class.java)
log.info { req }
}
@MessageMapping("/request/all")
fun requestAllAvailableActions(@Payload data: EventRequest) {
log.info { "Sending data to coordinator: ${Gson().toJson(data)}" }
val req = coordinatorTemplate.postForEntity("/request/all", data, String::class.java)
log.info { req }
}
}

View File

@ -1,19 +0,0 @@
package no.iktdev.mediaprocessing.ui.socket
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.messaging.handler.annotation.MessageMapping
import org.springframework.messaging.simp.SimpMessagingTemplate
import org.springframework.stereotype.Controller
@Controller
class PersistentEventsTableTopic(
@Autowired private val template: SimpMessagingTemplate?,
//@Autowired private val persistentEventsTableService: PersistentEventsTableService
) {
@MessageMapping("/persistent/events")
fun readbackEvents() {
//template?.convertAndSend("/topic/persistent/events", persistentEventsTableService.cachedEvents)
}
}

View File

@ -0,0 +1,56 @@
package no.iktdev.mediaprocessing.ui.socket
import no.iktdev.eventi.database.withTransaction
import no.iktdev.mediaprocessing.shared.common.contract.dto.ProcesserEventInfo
import no.iktdev.mediaprocessing.shared.common.database.cal.toTask
import no.iktdev.mediaprocessing.shared.common.database.tables.tasks
import no.iktdev.mediaprocessing.shared.common.task.Task
import no.iktdev.mediaprocessing.ui.WebSocketMonitoringService
import no.iktdev.mediaprocessing.ui.eventDatabase
import no.iktdev.mediaprocessing.ui.socket.a2a.ProcesserListenerService
import org.jetbrains.exposed.sql.selectAll
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.messaging.handler.annotation.MessageMapping
import org.springframework.messaging.simp.SimpMessagingTemplate
import org.springframework.stereotype.Service
@Service
class ProcesserTasksTopic(
@Autowired a2AProcesserService: ProcesserListenerService,
@Autowired private val webSocketMonitoringService: WebSocketMonitoringService,
@Autowired override var template: SimpMessagingTemplate?,
): SocketListener(template) {
final val a2a = object : ProcesserListenerService.A2AProcesserListener {
override fun onExtractProgress(info: ProcesserEventInfo) {
}
override fun onEncodeProgress(info: ProcesserEventInfo) {
}
override fun onEncodeAssigned() {
}
override fun onExtractAssigned() {
}
}
init {
a2AProcesserService.attachListener(a2a)
}
data class TaskGroup(
val referenceId: String,
val tasks: List<Task>
)
@MessageMapping("/tasks/all")
fun pullAllTasks() {
val result = withTransaction(eventDatabase.database) {
tasks.selectAll().toTask()
.groupBy { it.referenceId }.map { g -> TaskGroup(g.key, g.value) }
} ?: emptyList()
template?.convertAndSend("/topic/tasks/all", result)
}
}

View File

@ -0,0 +1,11 @@
package no.iktdev.mediaprocessing.ui.socket
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.messaging.simp.SimpMessagingTemplate
import org.springframework.stereotype.Controller
@Controller
class SocketListener(
@Autowired protected val template: SimpMessagingTemplate?,
) {
}

View File

@ -1,31 +1,68 @@
package no.iktdev.mediaprocessing.ui.socket
import no.iktdev.eventi.database.withTransaction
import no.iktdev.mediaprocessing.shared.common.contract.dto.EventRequest
import no.iktdev.exfl.observable.ObservableList
import no.iktdev.mediaprocessing.shared.common.contract.data.MediaProcessStartEvent
import no.iktdev.mediaprocessing.shared.common.contract.data.az
import no.iktdev.mediaprocessing.shared.common.contract.jsonToEvent
import no.iktdev.mediaprocessing.shared.common.database.tables.events
import no.iktdev.mediaprocessing.shared.common.database.tables.files
import no.iktdev.mediaprocessing.shared.common.database.tables.filesProcessed
import no.iktdev.mediaprocessing.ui.UIEnv
import no.iktdev.mediaprocessing.ui.WebSocketMonitoringService
import no.iktdev.mediaprocessing.ui.eventDatabase
import no.iktdev.mediaprocessing.ui.socket.UnprocessedFilesTopic.DatabaseData.filesInProcess
import no.iktdev.mediaprocessing.ui.socket.UnprocessedFilesTopic.DatabaseData.pullUnprocessedFiles
import no.iktdev.mediaprocessing.ui.socket.UnprocessedFilesTopic.DatabaseData.unprocessedFiles
import no.iktdev.mediaprocessing.ui.socket.UnprocessedFilesTopic.DatabaseData.update
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.selectAll
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.messaging.handler.annotation.MessageMapping
import org.springframework.messaging.handler.annotation.Payload
import org.springframework.messaging.simp.SimpMessagingTemplate
import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Controller
import org.springframework.web.client.RestTemplate
import java.io.File
import org.springframework.web.socket.WebSocketSession
import java.util.concurrent.ConcurrentHashMap
@Controller
@EnableScheduling
class UnprocessedFilesTopic(
@Autowired private val template: SimpMessagingTemplate?,
@Autowired private val coordinatorTemplate: RestTemplate,
@Autowired private val webSocketMonitoringService: WebSocketMonitoringService
) {
fun pullUnprocessedFiles(): List<FileInfo> = withTransaction(eventDatabase.database) {
files.select {
object DatabaseData {
private const val PULL_MIN_INTERVAL = 10_000 // 10 sekunder
private var lastPoll: Long = 0
var unprocessedFiles: List<FileInfo> = emptyList()
private set
var filesInProcess: List<FileInfo> = emptyList()
private set
// Funksjon som oppdaterer dataen hvis det har gått mer enn PULL_MIN_INTERVAL siden forrige oppdatering
fun update() {
val currentTime = System.currentTimeMillis()
// Sjekk om det har gått mer enn 10 sekunder (10000 ms) siden siste oppdatering
if (currentTime - lastPoll >= PULL_MIN_INTERVAL) {
// Oppdater tidspunktet for siste poll
lastPoll = currentTime
// Oppdater dataene ved å hente nye verdier
val filesNotCompleted = pullUnprocessedFiles()
filesInProcess = pullUncompletedFiles()
unprocessedFiles = filesNotCompleted.filter { u -> !filesInProcess.any { p -> p.checksum == u.checksum } }
}
}
private fun pullUnprocessedFiles(): List<FileInfo> = withTransaction(eventDatabase.database) {
val found = files.select {
files.checksum notInSubQuery filesProcessed.slice(filesProcessed.checksum).selectAll()
}.mapNotNull {
FileInfo(
@ -33,26 +70,35 @@ class UnprocessedFilesTopic(
it[files.fileName],
it[files.checksum]
)
}.filter { File(it.fileName).exists() }
}
unprocessedFiles = found
found//.filter { File(it.fileName).exists() }
} ?: emptyList()
@MessageMapping("/files")
fun getUnprocessedFiles() {
refreshUnprocessedFiles()
private fun pullUncompletedFiles(): List<FileInfo> = withTransaction(eventDatabase.database) {
val eventStartedFiles = events.select {
events.event eq "ProcessStarted"
}.mapNotNull { it[events.data].jsonToEvent(it[events.event]) }
.mapNotNull { it.az<MediaProcessStartEvent>() }
.mapNotNull { it.data?.file }
unprocessedFiles.filter { it.fileName in eventStartedFiles }.also {
filesInProcess = it
}
@MessageMapping("/request/process")
fun requestProcess(@Payload data: EventRequest) {
val req = coordinatorTemplate.postForEntity("/request/all", data, String::class.java)
log.info { "RequestProcess report:\n\tStatus: ${req.statusCode}\n\tMessage: ${req.body}" }
} ?: emptyList()
}
data class UnprocessedFiles(
val available: List<FileInfo>,
val inProcess: List<FileInfo>
)
@Scheduled(fixedDelay = 5_000)
fun refreshUnprocessedFiles() {
val unprocessedFiles = pullUnprocessedFiles()
template?.convertAndSend("/topic/files/unprocessed", unprocessedFiles)
@MessageMapping("/files/unprocessed")
fun postUnProcessedFiles() {
update()
template?.convertAndSend("/topic/files/unprocessed", UnprocessedFiles(
available = unprocessedFiles,
inProcess = filesInProcess
))
}
}

View File

@ -0,0 +1,97 @@
package no.iktdev.mediaprocessing.ui.socket.a2a
import com.google.gson.Gson
import mu.KotlinLogging
import no.iktdev.mediaprocessing.shared.common.contract.dto.ProcesserEventInfo
import no.iktdev.mediaprocessing.ui.UIEnv
import no.iktdev.mediaprocessing.ui.WebSocketMonitoringService
import no.iktdev.mediaprocessing.ui.log
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.messaging.simp.SimpMessagingTemplate
import org.springframework.messaging.simp.stomp.*
import org.springframework.stereotype.Service
import org.springframework.web.socket.client.standard.StandardWebSocketClient
import org.springframework.web.socket.messaging.WebSocketStompClient
import java.lang.reflect.Type
@Service
class ProcesserListenerService(
@Autowired private val webSocketMonitoringService: WebSocketMonitoringService,
@Autowired private val message: SimpMessagingTemplate?,
) {
private val logger = KotlinLogging.logger {}
private val listeners: MutableList<A2AProcesserListener> = mutableListOf()
fun attachListener(listener: A2AProcesserListener) {
listeners.add(listener)
}
val gson = Gson()
val client = WebSocketStompClient(StandardWebSocketClient())
init {
connectAndListen()
}
private final fun connectAndListen() {
log.info { "EncoderWsUrl: ${UIEnv.socketEncoder}" }
client.connect(UIEnv.socketEncoder, object : StompSessionHandlerAdapter() {
override fun afterConnected(session: StompSession, connectedHeaders: StompHeaders) {
super.afterConnected(session, connectedHeaders)
logger.info { "Tilkoblet processer" }
subscribeToTopics(session)
}
override fun handleException(
session: StompSession,
command: StompCommand?,
headers: StompHeaders,
payload: ByteArray,
exception: Throwable
) {
super.handleException(session, command, headers, payload, exception)
logger.error { "Feil ved tilkobling: ${exception.message}" }
}
})
}
private fun subscribeToTopics(session: StompSession) {
session.subscribe("/topic/encode/progress", encodeProcessFrameHandler)
session.subscribe("/topic/extract/progress", extractProcessFrameHandler)
}
private val encodeProcessFrameHandler = object : StompFrameHandler {
override fun getPayloadType(headers: StompHeaders): Type {
return ProcesserEventInfo::class.java
}
override fun handleFrame(headers: StompHeaders, payload: Any?) {
val response = gson.fromJson(payload.toString(), ProcesserEventInfo::class.java)
if (webSocketMonitoringService.anyListening()) {
message?.convertAndSend("/topic/processer/encode/progress", response)
}
}
}
private val extractProcessFrameHandler = object : StompFrameHandler {
override fun getPayloadType(headers: StompHeaders): Type {
return ProcesserEventInfo::class.java
}
override fun handleFrame(headers: StompHeaders, payload: Any?) {
val response = gson.fromJson(payload.toString(), ProcesserEventInfo::class.java)
if (webSocketMonitoringService.anyListening()) {
message?.convertAndSend("/topic/processer/extract/progress", response)
}
}
}
interface A2AProcesserListener {
fun onExtractProgress(info: ProcesserEventInfo)
fun onEncodeProgress(info: ProcesserEventInfo)
fun onEncodeAssigned()
fun onExtractAssigned()
}
}

View File

@ -1,77 +0,0 @@
package no.iktdev.mediaprocessing.ui.socket.internal
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import mu.KotlinLogging
import org.springframework.messaging.simp.stomp.StompFrameHandler
import org.springframework.messaging.simp.stomp.StompHeaders
import org.springframework.messaging.simp.stomp.StompSession
import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter
import org.springframework.stereotype.Service
import org.springframework.web.socket.client.standard.StandardWebSocketClient
import org.springframework.web.socket.messaging.WebSocketStompClient
import java.lang.reflect.Type
@Service
class EncoderReaderService {
private val logger = KotlinLogging.logger {}
/*fun startSubscription(session: StompSession) {
session.subscribe("/topic/encoder/workorder", object : StompFrameHandler {
override fun getPayloadType(headers: StompHeaders): Type {
return object : TypeToken<WorkOrderItem>() {}.type
//return object : TypeToken<List<WorkOrderItem?>?>() {}.type
}
override fun handleFrame(headers: StompHeaders, payload: Any?) {
if (payload is String) {
Gson().fromJson(payload, WorkOrderItem::class.java)?.let {
val item: EventDataObject = memActiveEventMap[it.id] ?: return
item.encode?.progress = it.progress
item.encode?.timeLeft = it.remainingTime
memActiveEventMap[it.id] = item;
memSimpleConvertedEventsMap[it.id] = item.toSimple()
}
}
}
})
session.subscribe("/topic/extractor/workorder", object : StompFrameHandler {
override fun getPayloadType(headers: StompHeaders): Type {
return object : TypeToken<WorkOrderItem>() {}.type
}
override fun handleFrame(headers: StompHeaders?, payload: Any?) {
if (payload is String) {
val item = Gson().fromJson(payload, WorkOrderItem::class.java)
}
}
})
}
val client = WebSocketStompClient(StandardWebSocketClient())
val sessionHandler = object : StompSessionHandlerAdapter() {
override fun afterConnected(session: StompSession, connectedHeaders: StompHeaders) {
super.afterConnected(session, connectedHeaders)
logger.info { "Connected to Encode Socket" }
startSubscription(session)
}
override fun handleFrame(headers: StompHeaders, payload: Any?) {
super.handleFrame(headers, payload)
}
}
init {
client.connect(UIEnv.socketEncoder, sessionHandler)
}*/
}

View File

@ -1,3 +1,4 @@
spring.output.ansi.enabled=always
logging.level.org.apache.kafka=INFO
logging.level.root=INFO
logging.level.org.springframework.web.socket.config.WebSocketMessageBrokerStats = INFO

View File

@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import logo from './logo.svg';
import './App.css';
import { Box, CssBaseline } from '@mui/material';
import { Box, CssBaseline, IconButton, SxProps, Theme } from '@mui/material';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Footer from './app/features/footer';
import LaunchPage from './app/page/LaunchPage';
@ -15,6 +15,13 @@ import theme from './theme';
import { simpleEventsUpdate } from './app/store/kafka-items-flat-slice';
import { EventDataObject, SimpleEventDataObject } from './types';
import EventsChainPage from './app/page/EventsChainPage';
import UnprocessedFilesPage from './app/page/UnprocessedFilesPage';
import AccountTreeIcon from '@mui/icons-material/AccountTree';
import FolderIcon from '@mui/icons-material/Folder';
import QueueIcon from '@mui/icons-material/Queue';
import AppsIcon from '@mui/icons-material/Apps';
import ConstructionIcon from '@mui/icons-material/Construction';
import ProcesserTasksPage from './app/page/ProcesserTasksPage';
function App() {
const client = useStompClient();
@ -44,14 +51,46 @@ function App() {
}, [client, dispatch]);
const iconHeight: SxProps<Theme> = {
height: 50,
width: 50
}
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Box sx={{
height: 70,
display: "flex",
alignItems: "center",
paddingLeft: 1,
backgroundColor: theme.palette.action.selected
}}>
<IconButton onClick={() => window.location.href = "/"} sx={{
...iconHeight
}}>
<AppsIcon />
</IconButton>
<IconButton onClick={() => window.location.href = "/events"} sx={{
...iconHeight
}}>
<AccountTreeIcon />
</IconButton>
<IconButton onClick={() => window.location.href = "/files"} sx={{
...iconHeight
}}>
<FolderIcon />
</IconButton>
<IconButton onClick={() => window.location.href = "/unprocessed"} sx={{
...iconHeight
}}>
<QueueIcon />
</IconButton>
<IconButton onClick={() => window.location.href = "/tasks"} sx={{
...iconHeight
}}>
<ConstructionIcon />
</IconButton>
</Box>
<Box sx={{
display: "block",
@ -62,6 +101,8 @@ function App() {
}}>
<BrowserRouter>
<Routes>
<Route path='/tasks' element={<ProcesserTasksPage />} />
<Route path='/unprocessed' element={<UnprocessedFilesPage />} />
<Route path='/files' element={<ExplorePage />} />
<Route path='/events' element={<EventsChainPage />} />
<Route path='/' element={<LaunchPage />} />

View File

@ -0,0 +1,156 @@
import { useEffect, useState } from "react";
import { useSelector } from "react-redux";
import { RootState } from "../../store";
import IconArrowUp from '@mui/icons-material/ArrowUpward';
import IconArrowDown from '@mui/icons-material/ArrowDownward';
import { Table, TableHead, TableRow, TableCell, TableBody, Typography, Box, useTheme, TableContainer } from "@mui/material";
import { TablePropetyConfig, TableCellCustomizer, TableRowActionEvents } from "./table";
import { title } from "process";
export interface TableItemGroup<T> {
title: string
items: Array<T>
}
export default function MultiListSortedTable<T>({ items, columns, customizer, onRowClickedEvent }: { items: Array<TableItemGroup<T>>, columns: Array<TablePropetyConfig>, customizer?: TableCellCustomizer<T>, onRowClickedEvent?: TableRowActionEvents<T> }) {
const muiTheme = useTheme();
const [order, setOrder] = useState<'asc' | 'desc'>('asc');
const [orderBy, setOrderBy] = useState<string>('');
const [selectedRow, setSelectedRow] = useState<T | null>(null);
const tableRowSingleClicked = (row: T | null) => {
setSelectedRow(row);
if (row && onRowClickedEvent) {
onRowClickedEvent.click(row);
}
}
const tableRowDoubleClicked = (row: T | null) => {
setSelectedRow(row);
if (row && onRowClickedEvent) {
onRowClickedEvent.doubleClick(row);
}
}
const tableRowContextMenu = (e: React.MouseEvent<HTMLTableRowElement, MouseEvent> , row: T | null) => {
if (row && onRowClickedEvent && onRowClickedEvent.contextMenu) {
e.preventDefault()
onRowClickedEvent.contextMenu(row, e.pageX, e.pageY)
}
}
const handleSort = (property: string) => {
const isAsc = orderBy === property && order === 'asc';
setOrder(isAsc ? 'desc' : 'asc');
setOrderBy(property);
};
const compareValues = (a: any, b: any, orderBy: string) => {
if (typeof a[orderBy] === 'string') {
return a[orderBy].localeCompare(b[orderBy]);
} else if (typeof a[orderBy] === 'number') {
return a[orderBy] - b[orderBy];
}
return 0;
};
const sortedData: Array<TableItemGroup<T>> = items.map((item: TableItemGroup<T>) => {
return {
title: item.title,
items: item.items.slice().sort((a, b) => {
if (order === 'asc') {
return compareValues(a, b, orderBy);
} else {
return compareValues(b, a, orderBy);
}
})
}
});
useEffect(() => {
handleSort(columns[0].accessor)
}, [])
return (
<Box sx={{
display: "flex",
flexDirection: "column", // Bruk column-fleksretning
height: "100%",
overflow: "hidden"
}}>
<TableContainer sx={{
flex: 1,
overflowY: "auto",
position: "relative", // Legg til denne linjen for å justere layout
maxHeight: "100%" // Legg til denne linjen for å begrense høyden
}}>
<Table>
<TableHead sx={{
position: "sticky",
top: 0,
backgroundColor: muiTheme.palette.background.paper,
}}>
<TableRow>
{columns.map((column) => (
<TableCell key={column.accessor} onClick={() => handleSort(column.accessor)} sx={{ cursor: "pointer" }}>
<Box display="flex">
{orderBy === column.accessor ?
(order === "asc" ? (<IconArrowDown />) : (<IconArrowUp />)) : (
<IconArrowDown sx={{ color: "transparent" }} />
)
}
<Typography>{column.label}</Typography>
</Box>
</TableCell>
))}
</TableRow>
</TableHead>
{sortedData.map((item, index) => (
<>
<TableHead
sx={{
position: "sticky",
top: 55,
backgroundColor: muiTheme.palette.primary.dark,
}}>
<TableRow>
<TableCell colSpan={columns.length}>
<Typography variant='h6'>{item.title}</Typography>
</TableCell>
</TableRow>
</TableHead>
<TableBody sx={{
overflowY: "scroll"
}}>
{item.items?.map((row: T, rowIndex: number) => (
<TableRow key={rowIndex}
onClick={() => tableRowSingleClicked(row)}
onDoubleClick={() => tableRowDoubleClicked(row)}
onContextMenu={(e) => {
tableRowContextMenu(e, row);
tableRowSingleClicked(row);
}}
style={{ cursor: "pointer", backgroundColor: selectedRow === row ? muiTheme.palette.action.selected : '' }}
>
{columns.map((column) => (
<TableCell key={column.accessor}>
{customizer && customizer(column.accessor, row) !== null
? customizer(column.accessor, row)
: <Typography variant='body1'>{(row as any)[column.accessor]}</Typography>}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</>
))}
</Table>
</TableContainer>
</Box>
)
}

View File

@ -0,0 +1,173 @@
import { useEffect, useState } from "react";
import { useSelector } from "react-redux";
import { RootState } from "../../store";
import IconArrowUp from '@mui/icons-material/ArrowUpward';
import IconArrowDown from '@mui/icons-material/ArrowDownward';
import { Table, TableHead, TableRow, TableCell, TableBody, Typography, Box, useTheme, TableContainer, IconButton } from "@mui/material";
import { TablePropetyConfig, TableCellCustomizer, TableRowActionEvents } from "./table";
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
export interface TableItemGroup<T> {
title: string
items: Array<T>
}
export default function SortableGroupedTable<T>({ items, columns, customizer, onRowClickedEvent }: { items: Array<TableItemGroup<T>>, columns: Array<TablePropetyConfig>, customizer?: TableCellCustomizer<T>, onRowClickedEvent?: TableRowActionEvents<T> }) {
const muiTheme = useTheme();
const [order, setOrder] = useState<'asc' | 'desc'>('asc');
const [orderBy, setOrderBy] = useState<string>('');
const [selectedRow, setSelectedRow] = useState<T | null>(null);
const [expandedRows, setExpandedRows] = useState<Set<number>>(new Set());
const toggleExpand = (index: number) => {
setExpandedRows((prev) => {
const newSet = new Set(prev);
if (newSet.has(index)) {
newSet.delete(index); // Collapse if already expanded
} else {
newSet.add(index); // Expand if not already expanded
}
return newSet;
});
};
const tableRowSingleClicked = (row: T | null) => {
setSelectedRow(row);
if (row && onRowClickedEvent) {
onRowClickedEvent.click(row);
}
}
const tableRowDoubleClicked = (row: T | null) => {
setSelectedRow(row);
if (row && onRowClickedEvent) {
onRowClickedEvent.doubleClick(row);
}
}
const tableRowContextMenu = (e: React.MouseEvent<HTMLTableRowElement, MouseEvent> , row: T | null) => {
if (row && onRowClickedEvent && onRowClickedEvent.contextMenu) {
e.preventDefault()
onRowClickedEvent.contextMenu(row, e.pageX, e.pageY)
}
}
const handleSort = (property: string) => {
const isAsc = orderBy === property && order === 'asc';
setOrder(isAsc ? 'desc' : 'asc');
setOrderBy(property);
};
const compareValues = (a: any, b: any, orderBy: string) => {
if (typeof a[orderBy] === 'string') {
return a[orderBy].localeCompare(b[orderBy]);
} else if (typeof a[orderBy] === 'number') {
return a[orderBy] - b[orderBy];
}
return 0;
};
const sortedData: Array<TableItemGroup<T>> = items.map((item: TableItemGroup<T>) => {
return {
title: item.title,
items: item.items.slice().sort((a, b) => {
if (order === 'asc') {
return compareValues(a, b, orderBy);
} else {
return compareValues(b, a, orderBy);
}
})
}
});
useEffect(() => {
handleSort(columns[0].accessor)
}, [])
return (
<Box sx={{
display: "flex",
flexDirection: "column", // Bruk column-fleksretning
height: "100%",
overflow: "hidden"
}}>
<TableContainer sx={{
flex: 1,
overflowY: "auto",
position: "relative", // Legg til denne linjen for å justere layout
maxHeight: "100%" // Legg til denne linjen for å begrense høyden
}}>
<Table>
<TableHead sx={{
position: "sticky",
top: 0,
backgroundColor: muiTheme.palette.background.paper,
}}>
<TableRow>
{columns.map((column) => (
<TableCell key={column.accessor} onClick={() => handleSort(column.accessor)} sx={{ cursor: "pointer" }}>
<Box display="flex">
{orderBy === column.accessor ?
(order === "asc" ? (<IconArrowDown />) : (<IconArrowUp />)) : (
<IconArrowDown sx={{ color: "transparent" }} />
)
}
<Typography>{column.label}</Typography>
</Box>
</TableCell>
))}
</TableRow>
</TableHead>
{sortedData.map((item, index) => (
<>
<TableHead
sx={{
backgroundColor: muiTheme.palette.primary.dark,
}}>
<TableRow>
<TableCell colSpan={columns.length}>
<Box onClick={() => toggleExpand(index)} sx={{ display: "flex", justifyContent: "space-between" }}>
<Typography variant='h6'>{item.title}</Typography>
<IconButton onClick={() => toggleExpand(index)}>
{expandedRows.has(index) ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
</Box>
</TableCell>
</TableRow>
</TableHead>
<TableBody sx={{
display: expandedRows.has(index) ? 'table-row-group' : 'none',
overflowY: "scroll"
}}>
{item.items?.map((row: T, rowIndex: number) => (
<TableRow key={rowIndex}
onClick={() => tableRowSingleClicked(row)}
onDoubleClick={() => tableRowDoubleClicked(row)}
onContextMenu={(e) => {
tableRowContextMenu(e, row);
tableRowSingleClicked(row);
}}
style={{ cursor: "pointer", backgroundColor: selectedRow === row ? muiTheme.palette.action.selected : '' }}
>
{columns.map((column) => (
<TableCell key={column.accessor}>
{customizer && customizer(column.accessor, row) !== null
? customizer(column.accessor, row)
: <Typography variant='body1'>{(row as any)[column.accessor]}</Typography>}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</>
))}
</Table>
</TableContainer>
</Box>
)
}

View File

@ -0,0 +1,98 @@
import { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useStompClient } from "react-stomp-hooks";
import { Box, Button, Grid, TextField, Typography, useTheme } from '@mui/material';
import { ExplorerItem } from "../../types";
import { ContextMenuItem } from "../features/ContextMenu";
import { RootState } from "../store";
import { useWsSubscription } from "../ws/subscriptions";
import SimpleTable from "../features/table/sortableTable";
import { TableCellCustomizer, TablePropetyConfig } from "../features/table/table";
import MultiListSortedTable from "../features/table/multiListSortedTable";
import { TableTaskGroup, Task, TaskGroup, update } from "../store/tasks-slice";
import SortableGroupedTable from "../features/table/sortableGroupedTable";
import { UnixTimestamp } from "../features/UxTc";
const columns: Array<TablePropetyConfig> = [
{ label: "Name", accessor: "data.inputFile" },
{ label: "Task", accessor: "task" },
{ label: "Status", accessor: "status" },
{ label: "Created", accessor: "created" },
];
const createTableCell: TableCellCustomizer<Task> = (accessor, data) => {
switch (accessor) {
case "created": {
if (typeof data[accessor] === "string") {
return UnixTimestamp({ timestamp: Date.parse(data[accessor]) });
}
return null;
}
case "data.inputFile": {
const parts = data.data?.inputFile.split("/") ?? [];
return <Typography>{parts[parts?.length - 1]}</Typography>
}
default:
return null;
}
};
export default function ProcesserTasksPage() {
const muiTheme = useTheme();
const dispatch = useDispatch();
const client = useStompClient();
const taskGroups = useSelector((state: RootState) => state.tasks);
useWsSubscription<Array<TaskGroup>>("/topic/tasks/all", (response) => {
console.log(response)
dispatch(update(response))
});
useEffect(() => {
client?.publish({
destination: "/app/tasks/all"
});
}, [client, dispatch]);
return (
<>
<Box display="block">
<Grid container sx={{
height: 50,
width: "100%",
maxHeight: "100%",
overflow: "hidden",
display: "flex",
alignItems: "center",
backgroundColor: muiTheme.palette.background.paper
}}>
<Grid item xs={2}>
<Typography variant="h6">Tasks</Typography>
</Grid>
<Grid item xs={10}>
<TextField
hiddenLabel
placeholder="Search"
fullWidth={true}
id="search-field"
variant="filled"
/>
</Grid>
</Grid>
<Box sx={{
display: "block",
height: "calc(100% - 120px)",
overflow: "hidden",
position: "absolute",
width: "100%"
}}>
<SortableGroupedTable items={taskGroups.items ?? []} columns={columns} customizer={createTableCell} />
</Box>
</Box>
</>
)
}

View File

@ -0,0 +1,108 @@
import { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useStompClient } from "react-stomp-hooks";
import { Box, Button, Grid, TextField, Typography, useTheme } from '@mui/material';
import { ExplorerItem } from "../../types";
import { ContextMenuItem } from "../features/ContextMenu";
import { RootState } from "../store";
import { useWsSubscription } from "../ws/subscriptions";
import { FileInfo, FileInfoGroup, IncomingUnprocessedFiles, update, } from "../store/unprocessed-files-slice";
import SimpleTable from "../features/table/sortableTable";
import { TablePropetyConfig } from "../features/table/table";
import MultiListSortedTable from "../features/table/multiListSortedTable";
const columns: Array<TablePropetyConfig> = [
{ label: "Name", accessor: "name" },
{ label: "Checksum", accessor: "checksum" },
];
export default function UnprocessedFilesPage() {
const muiTheme = useTheme();
const dispatch = useDispatch();
const client = useStompClient();
const files = useSelector((state: RootState) => state.unprocessedFiles);
const [tableItems, setTableItems] = useState<Array<FileInfoGroup>>([]);
const [selectedRow, setSelectedRow] = useState<ExplorerItem|null>(null);
const [actionableItems, setActionableItems] = useState<Array<ContextMenuItem>>([]);
useWsSubscription<IncomingUnprocessedFiles>("/topic/files/unprocessed", (response) => {
dispatch(update(response))
});
const pullData = () => {
client?.publish({
destination: "/app/files/unprocessed"
});
}
useEffect(() => {
client?.publish({
destination: "/app/files/unprocessed"
});
const intervalId = setInterval(pullData, 20000);
return () => {
clearInterval(intervalId); // Fjern intervallet når komponenten fjernes fra DOM
};
}, [client, dispatch]);
useEffect(() => {
const entries = [
{
title: "In Process",
items: files.inProcess
},
{
title: "Available",
items: files.available
}
];
setTableItems(entries);
console.log(entries)
}, [files])
return (
<>
<Box display="block">
<Grid container sx={{
height: 50,
width: "100%",
maxHeight: "100%",
overflow: "hidden",
display: "flex",
alignItems: "center",
backgroundColor: muiTheme.palette.background.paper
}}>
<Grid item xs={2}>
<Typography variant="h6">Unprocessed Files</Typography>
</Grid>
<Grid item xs={10}>
<TextField
hiddenLabel
placeholder="Search"
fullWidth={true}
id="search-field"
variant="filled"
/>
</Grid>
</Grid>
<Box sx={{
display: "block",
height: "calc(100% - 120px)",
overflow: "hidden",
position: "absolute",
width: "100%"
}}>
<MultiListSortedTable items={tableItems ?? []} columns={columns} />
</Box>
</Box>
</>
)
}

View File

@ -5,6 +5,8 @@ import kafkaItemsFlatSlice from './store/kafka-items-flat-slice';
import contextMenuSlice from './store/context-menu-slice';
import persistentEventsSlice from './store/persistent-events-slice';
import chainedEventsSlice from './store/chained-events-slice';
import unprocessedFilesSlice from './store/unprocessed-files-slice';
import tasksSlice from './store/tasks-slice';
export const store = configureStore({
@ -15,6 +17,8 @@ export const store = configureStore({
contextMenu: contextMenuSlice,
persistentEvents: persistentEventsSlice,
chained: chainedEventsSlice,
unprocessedFiles: unprocessedFilesSlice,
tasks: tasksSlice,
},
});

View File

@ -0,0 +1,95 @@
import { PayloadAction, createSlice } from "@reduxjs/toolkit"
import { TableItemGroup } from "../features/table/multiListSortedTable";
export enum TaskType {
Encode = 'Encode',
Extract = 'Extract',
Convert = 'Convert'
}
export interface TaskData {
inputFile: string;
}
export enum SubtitleFormats {
SRT = 'SRT',
VTT = 'VTT',
ASS = 'ASS',
SUB = 'SUB'
}
export interface EncodeArgumentData extends TaskData {
arguments: string[];
outputFileName: string;
}
export interface ExtractArgumentData extends TaskData {
arguments: string[];
language: string;
storeFileName: string;
outputFileName: string;
}
export interface ConvertData extends TaskData {
language: string;
outputDirectory: string;
outputFileName: string;
storeFileName: string;
formats: SubtitleFormats[];
allowOverwrite: boolean;
}
export interface Task {
referenceId: string;
status?: string | null;
claimed: boolean;
claimedBy?: string | null;
consumed: boolean;
task: TaskType;
eventId: string;
derivedFromEventId?: string | null;
data?: EncodeArgumentData | ExtractArgumentData | ConvertData | null;
created: string; // Bruk ISO-dato som string
lastCheckIn?: string | null;
}
export interface TaskGroup{
referenceId: string
tasks: Array<Task>
}
export interface TaskGroupList {
items: Array<TaskGroup>
}
export interface TableTaskGroup extends TableItemGroup<Task> {
title: string
items: Array<Task>
}
export interface TableTaskGroupList {
items: Array<TableTaskGroup>
}
const initialState: TableTaskGroupList = {
items: []
}
const tasksSlice = createSlice({
name: "Tasks",
initialState,
reducers: {
update(state, action: PayloadAction<Array<TaskGroup>>) {
state.items = action.payload.map((value) => ({
title: value.referenceId,
items: value.tasks
})) ?? []
},
}
});
export const { update } = tasksSlice.actions;
export default tasksSlice.reducer;

View File

@ -0,0 +1,39 @@
import { PayloadAction, createSlice } from "@reduxjs/toolkit"
import { ExplorerItem, ExplorerCursor } from "../../types"
import { TableItemGroup } from "../features/table/multiListSortedTable"
import exp from "constants"
export interface FileInfo {
name: string
fileName: string
checksum: string
}
export interface FileInfoGroup extends TableItemGroup<FileInfo> {
title: string
items: Array<FileInfo>
}
export interface IncomingUnprocessedFiles {
available: Array<FileInfo>
inProcess: Array<FileInfo>
}
const initialState: IncomingUnprocessedFiles = {
available: [],
inProcess: []
}
const unprocessedFilesSlice = createSlice({
name: "UnprocessedFiles",
initialState,
reducers: {
update(state, action: PayloadAction<IncomingUnprocessedFiles>) {
state.available = action.payload.available ?? []
state.inProcess = action.payload.inProcess ?? []
},
}
})
export const { update } = unprocessedFilesSlice.actions;
export default unprocessedFilesSlice.reducer;

View File

@ -13,10 +13,10 @@ const root = ReactDOM.createRoot(
const wsUrl = () => {
const protocol = window.location.protocol;
const protocol = "ws" // window.location.protocol;
const host = window.location.host;
if (window.location.href.startsWith("http://localhost:3000")) {
return "http://localhost:8080/ws";
return "ws://localhost:8080/ws";
} else {
return `${protocol}//${host}/ws`;
}
@ -31,7 +31,7 @@ root.render(
console.log(str);
}}
onUnhandledMessage={(val) => {
console.log(val)
console.log("Unhandled message", val)
}}
onStompError={(val) => {
console.log(val)