UI Wip
This commit is contained in:
parent
701c939e8d
commit
e69f8b7ff8
@ -20,37 +20,33 @@ repositories {
|
||||
}
|
||||
}
|
||||
|
||||
val exposedVersion = "0.44.0"
|
||||
|
||||
dependencies {
|
||||
implementation(kotlin("stdlib-jdk8"))
|
||||
|
||||
|
||||
implementation("org.springframework.boot:spring-boot-starter-web:3.0.4")
|
||||
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.14.2")
|
||||
// Kotlin
|
||||
implementation(kotlin("stdlib"))
|
||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
||||
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
|
||||
|
||||
implementation("com.google.code.gson:gson:2.9.0")
|
||||
implementation("org.springframework.boot:spring-boot-starter-websocket:2.6.3")
|
||||
// Spring Boot (WebFlux gir deg SSE + non-blocking IO)
|
||||
implementation("org.springframework.boot:spring-boot-starter-web")
|
||||
implementation("org.springframework.boot:spring-boot-starter-actuator")
|
||||
implementation("org.springframework:spring-webflux")
|
||||
|
||||
// JSON (Jackson Kotlin)
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
|
||||
|
||||
// Logging
|
||||
implementation("io.github.microutils:kotlin-logging-jvm:2.0.11")
|
||||
implementation("com.github.vishna:watchservice-ktx:master-SNAPSHOT")
|
||||
|
||||
implementation("org.jetbrains.exposed:exposed-core:$exposedVersion")
|
||||
implementation("org.jetbrains.exposed:exposed-dao:$exposedVersion")
|
||||
implementation("org.jetbrains.exposed:exposed-jdbc:$exposedVersion")
|
||||
implementation("org.jetbrains.exposed:exposed-java-time:$exposedVersion")
|
||||
implementation ("mysql:mysql-connector-java:8.0.29")
|
||||
|
||||
// Dine custom libs
|
||||
implementation(libs.exfl)
|
||||
implementation(project(mapOf("path" to ":shared:common")))
|
||||
|
||||
implementation(project(":shared:common"))
|
||||
|
||||
// Testing
|
||||
testImplementation(platform("org.junit:junit-bom:5.9.1"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
}
|
||||
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
@ -0,0 +1,98 @@
|
||||
package no.iktdev.mediaprocessing.ui
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
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.web.client.RestTemplate
|
||||
import org.springframework.web.reactive.function.client.WebClient
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
|
||||
|
||||
@Configuration
|
||||
class WebConfig : WebMvcConfigurer {
|
||||
|
||||
override fun addCorsMappings(registry: CorsRegistry) {
|
||||
registry.addMapping("/**")
|
||||
.allowedOrigins(
|
||||
"http://localhost:5173",
|
||||
"http://localhost:3000",
|
||||
"http://localhost",
|
||||
"http://localhost:80"
|
||||
)
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(true)
|
||||
}
|
||||
|
||||
@Value("\${APP_DEPLOYMENT_PORT:8080}")
|
||||
private val deploymentPort = 8080
|
||||
|
||||
@Bean
|
||||
fun webServerFactoryCustomizer(): WebServerFactoryCustomizer<TomcatServletWebServerFactory> {
|
||||
return WebServerFactoryCustomizer { factory ->
|
||||
factory.port = deploymentPort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ConfigurationProperties(prefix = "media")
|
||||
data class MediaConfig(
|
||||
var cache: String = "",
|
||||
var cacheRewrite: Rewrite? = null,
|
||||
|
||||
var outgoing: String = "",
|
||||
var outgoingRewrite: Rewrite? = null,
|
||||
|
||||
var incoming: String = "",
|
||||
var incomingRewrite: Rewrite? = null
|
||||
) {
|
||||
data class Rewrite(
|
||||
var to: String = ""
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ConfigurationProperties(prefix = "mediaprocessing.apps")
|
||||
data class AppsConfig(
|
||||
val coordinator: AppConfig,
|
||||
val processer: AppConfig,
|
||||
val converter: AppConfig,
|
||||
val metadata: AppConfig,
|
||||
val watcher: AppConfig
|
||||
)
|
||||
|
||||
data class AppConfig(
|
||||
val address: String,
|
||||
val health: String
|
||||
)
|
||||
|
||||
|
||||
@Configuration
|
||||
class HttpConfig {
|
||||
@Bean
|
||||
fun restTemplate() = RestTemplate()
|
||||
}
|
||||
|
||||
@Configuration
|
||||
class WebClientConfig(
|
||||
private val appsConfig: AppsConfig
|
||||
) {
|
||||
|
||||
@Bean
|
||||
fun coordinatorWebClient(builder: WebClient.Builder): WebClient =
|
||||
builder
|
||||
.codecs { it.defaultCodecs().maxInMemorySize(10 * 1024 * 1024) }
|
||||
.baseUrl(appsConfig.coordinator.address).build()
|
||||
|
||||
@Bean
|
||||
fun webClient(): WebClient.Builder =
|
||||
WebClient
|
||||
.builder()
|
||||
.codecs { it.defaultCodecs().maxInMemorySize(10 * 1024 * 1024) }
|
||||
|
||||
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
package no.iktdev.mediaprocessing.ui
|
||||
|
||||
import mu.KotlinLogging
|
||||
import org.springframework.boot.ApplicationArguments
|
||||
import org.springframework.boot.ApplicationRunner
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.reactive.function.client.WebClient
|
||||
|
||||
@Component
|
||||
class CoordinatorHealthCheck(
|
||||
private val webClient: WebClient
|
||||
) : ApplicationRunner {
|
||||
|
||||
private val log = KotlinLogging.logger {}
|
||||
|
||||
override fun run(args: ApplicationArguments?) {
|
||||
webClient.get()
|
||||
.uri("/actuator/health")
|
||||
.retrieve()
|
||||
.bodyToMono(String::class.java)
|
||||
.doOnNext { log.info { "Coordinator is reachable" } }
|
||||
.doOnError { log.error(it) { "Coordinator is NOT reachable" } }
|
||||
.subscribe()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
package no.iktdev.mediaprocessing.ui
|
||||
|
||||
import mu.KotlinLogging
|
||||
import no.iktdev.exfl.coroutines.CoroutinesDefault
|
||||
import no.iktdev.exfl.coroutines.CoroutinesIO
|
||||
import no.iktdev.exfl.observable.Observables
|
||||
import no.iktdev.mediaprocessing.shared.common.getAppVersion
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
import org.springframework.boot.runApplication
|
||||
import org.springframework.scheduling.annotation.EnableScheduling
|
||||
|
||||
val log = KotlinLogging.logger {}
|
||||
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableConfigurationProperties(AppsConfig::class, MediaConfig::class)
|
||||
@EnableScheduling
|
||||
class UIApplication {
|
||||
}
|
||||
|
||||
|
||||
val ioCoroutine = CoroutinesIO()
|
||||
val defaultCoroutine = CoroutinesDefault()
|
||||
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
|
||||
|
||||
ioCoroutine.addListener(listener = object: Observables.ObservableValue.ValueListener<Throwable> {
|
||||
override fun onUpdated(value: Throwable) {
|
||||
value.printStackTrace()
|
||||
}
|
||||
})
|
||||
defaultCoroutine.addListener(listener = object: Observables.ObservableValue.ValueListener<Throwable> {
|
||||
override fun onUpdated(value: Throwable) {
|
||||
value.printStackTrace()
|
||||
}
|
||||
})
|
||||
|
||||
runApplication<UIApplication>(*args)
|
||||
log.info { "App Version: ${getAppVersion()}" }
|
||||
}
|
||||
|
||||
@ -0,0 +1,89 @@
|
||||
package no.iktdev.mediaprocessing.ui
|
||||
|
||||
import jakarta.annotation.PostConstruct
|
||||
import no.iktdev.mediaprocessing.ui.dto.SSEMessage
|
||||
import no.iktdev.mediaprocessing.ui.service.CoordinatorClient
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
@Component
|
||||
class UiSseHub(
|
||||
private val coordinator: CoordinatorClient,
|
||||
) {
|
||||
private val emitters = CopyOnWriteArrayList<SseEmitter>()
|
||||
private var listeners: MutableList<SSEStateListener> = mutableListOf()
|
||||
private var currentState: CurrentState = CurrentState.DISCONNECTED
|
||||
|
||||
init {
|
||||
log.info { "UiSseHub initialized" }
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
fun startSSE() {
|
||||
log.info { "Starting connection to SSE" }
|
||||
coordinator.connectToSse(onConnected = {
|
||||
currentState = CurrentState.CONNECTED
|
||||
log.info { "Connected to SSE" }
|
||||
listeners.onEach { l -> l.onConnected() } }, onReconnecting = {
|
||||
currentState = CurrentState.RECONNECTING
|
||||
listeners.onEach { l -> l.onReconnecting() }
|
||||
}, onDisconnected = {
|
||||
currentState = CurrentState.DISCONNECTED
|
||||
log.warn { "Lost connection to SSE" }
|
||||
listeners.onEach { l -> l.onDisconnected() }
|
||||
}).subscribe { event ->
|
||||
val eventName = event.event()
|
||||
if (eventName == null) {
|
||||
log.error("Received SSE event with no event name ${event.data()}")
|
||||
} else {
|
||||
broadcast(event, eventName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun registerListener(listener: SSEStateListener) {
|
||||
listeners.add(listener)
|
||||
when (currentState) {
|
||||
CurrentState.CONNECTED -> listener.onConnected()
|
||||
CurrentState.RECONNECTING -> listener.onReconnecting()
|
||||
CurrentState.DISCONNECTED -> listener.onDisconnected()
|
||||
}
|
||||
}
|
||||
|
||||
fun createEmitter(): SseEmitter {
|
||||
val emitter = SseEmitter(0L)
|
||||
emitters.add(emitter)
|
||||
|
||||
emitter.onCompletion { emitters.remove(emitter) }
|
||||
emitter.onTimeout { emitters.remove(emitter) }
|
||||
|
||||
return emitter
|
||||
}
|
||||
|
||||
fun broadcast(event: Any, name: String) {
|
||||
val dead = mutableListOf<SseEmitter>()
|
||||
emitters.forEach { emitter ->
|
||||
try {
|
||||
val data = SSEMessage(name, event)
|
||||
val builder = SseEmitter.event().data(data)
|
||||
emitter.send(builder)
|
||||
} catch (ex: Exception) {
|
||||
dead.add(emitter)
|
||||
}
|
||||
}
|
||||
emitters.removeAll(dead)
|
||||
}
|
||||
|
||||
enum class CurrentState {
|
||||
CONNECTED,
|
||||
RECONNECTING,
|
||||
DISCONNECTED
|
||||
}
|
||||
|
||||
interface SSEStateListener {
|
||||
fun onConnected()
|
||||
fun onReconnecting()
|
||||
fun onDisconnected()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
package no.iktdev.mediaprocessing.ui.controller
|
||||
|
||||
import org.springframework.core.io.ClassPathResource
|
||||
import org.springframework.stereotype.Controller
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
|
||||
@Controller
|
||||
class EmbeddedWebsiteController {
|
||||
|
||||
// Root fallback
|
||||
@GetMapping("/")
|
||||
fun root(): String {
|
||||
val index = ClassPathResource("static/index.html")
|
||||
return if (index.exists()) "forward:/index.html" else "forward:/noop"
|
||||
}
|
||||
|
||||
// Fallback for React Router paths
|
||||
@GetMapping("/{path:[^\\.]*}")
|
||||
fun forward(path: String): String {
|
||||
val index = ClassPathResource("static/index.html")
|
||||
return if (index.exists()) "forward:/index.html" else "forward:/noop"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
package no.iktdev.mediaprocessing.ui.controller
|
||||
|
||||
import no.iktdev.mediaprocessing.ui.MediaConfig
|
||||
import no.iktdev.mediaprocessing.ui.dto.file.IFile
|
||||
import no.iktdev.mediaprocessing.ui.service.ExplorerService
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.io.File
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/files")
|
||||
class FileExploreController(
|
||||
private val mediaConfig: MediaConfig,
|
||||
private val explorer: ExplorerService
|
||||
) {
|
||||
|
||||
@GetMapping("/home")
|
||||
fun home(): ResponseEntity<List<IFile>> {
|
||||
return ResponseEntity.ok(explorer.listHome())
|
||||
}
|
||||
|
||||
@GetMapping("/roots")
|
||||
fun roots(): ResponseEntity<List<IFile>> {
|
||||
return ResponseEntity.ok(
|
||||
listOfNotNull(
|
||||
explorer.pathToFile(mediaConfig.incoming),
|
||||
explorer.pathToFile(mediaConfig.cache),
|
||||
explorer.pathToFile(mediaConfig.outgoing)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@GetMapping("/explore")
|
||||
fun list(@RequestParam path: String): ResponseEntity<List<IFile>> {
|
||||
val file = File(path)
|
||||
if (!file.exists() || file.isFile) {
|
||||
return ResponseEntity.notFound().build()
|
||||
}
|
||||
val files = explorer.listAt(path)
|
||||
return ResponseEntity.ok(files)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,149 @@
|
||||
package no.iktdev.mediaprocessing.ui.controller
|
||||
|
||||
import no.iktdev.mediaprocessing.shared.common.dto.*
|
||||
import no.iktdev.mediaprocessing.ui.UiSseHub
|
||||
import no.iktdev.mediaprocessing.ui.dto.Paginated
|
||||
import no.iktdev.mediaprocessing.ui.dto.UiEvent
|
||||
import no.iktdev.mediaprocessing.ui.dto.UiTask
|
||||
import no.iktdev.mediaprocessing.ui.dto.health.CoordinatorHealth
|
||||
import no.iktdev.mediaprocessing.ui.dto.health.DiskInfo
|
||||
import no.iktdev.mediaprocessing.ui.dto.rate.EventRate
|
||||
import no.iktdev.mediaprocessing.ui.dto.requests.ContinueResult
|
||||
import no.iktdev.mediaprocessing.ui.dto.requests.StartProcessRequest
|
||||
import no.iktdev.mediaprocessing.ui.dto.status.SystemStatus
|
||||
import no.iktdev.mediaprocessing.ui.service.CoordinatorClient
|
||||
import no.iktdev.mediaprocessing.ui.service.MediaPathRewriteService
|
||||
import no.iktdev.mediaprocessing.ui.service.StatusService
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.*
|
||||
import org.springframework.web.reactive.function.client.WebClientResponseException
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter
|
||||
import reactor.core.publisher.Mono
|
||||
import java.util.*
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
class UiApiController(
|
||||
private val coordinator: CoordinatorClient,
|
||||
private val statusService: StatusService,
|
||||
private val hub: UiSseHub,
|
||||
private val mediaPathRewriteService: MediaPathRewriteService,
|
||||
) {
|
||||
|
||||
@GetMapping("/sequences/active")
|
||||
fun getActive(): Mono<List<SequenceSummary>> =
|
||||
coordinator.getActiveSequences()
|
||||
|
||||
@GetMapping("/sequences/recent")
|
||||
fun getRecent(@RequestParam(defaultValue = "15") limit: Int): Mono<List<SequenceSummary>> =
|
||||
coordinator.getRecentSequences(limit)
|
||||
|
||||
@GetMapping("/sequences")
|
||||
fun getEventSequences(
|
||||
@RequestParam referenceId: UUID,
|
||||
@RequestParam(required = false) beforeEventId: UUID?,
|
||||
@RequestParam(required = false) afterEventId: UUID?,
|
||||
@RequestParam(defaultValue = "50") limit: Int
|
||||
): Mono<List<SequenceEvent>> =
|
||||
coordinator.getEventSequence(referenceId, beforeEventId, afterEventId, limit)
|
||||
|
||||
@PostMapping("/sequences/{referenceId}/continue")
|
||||
fun continueSequence(@PathVariable referenceId: UUID): ResponseEntity<String> {
|
||||
return when (val result = coordinator.continueSequence(referenceId)) {
|
||||
is ContinueResult.Success ->
|
||||
ResponseEntity.ok("Action accepted!")
|
||||
|
||||
is ContinueResult.Failure ->
|
||||
ResponseEntity.status(HttpStatus.BAD_REQUEST).body(result.message)
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/sequences/{referenceId}/delete")
|
||||
fun deleteSequence(@PathVariable referenceId: UUID): ResponseEntity<String> {
|
||||
return when (val result = coordinator.deleteSequence(referenceId)) {
|
||||
is ContinueResult.Success ->
|
||||
ResponseEntity.ok("Action accepted!")
|
||||
|
||||
is ContinueResult.Failure ->
|
||||
ResponseEntity.status(HttpStatus.BAD_REQUEST).body(result.message)
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/events")
|
||||
fun getEvents(query: EventQuery): Mono<Paginated<UiEvent>> {
|
||||
return coordinator.getPagedEvents(query)
|
||||
}
|
||||
|
||||
@GetMapping("/events/history/{referenceId}/effective")
|
||||
fun getEffectiveHistory(
|
||||
@PathVariable referenceId: UUID,
|
||||
): Mono<List<UiEvent>> {
|
||||
return coordinator.getEffectiveHistory(referenceId)
|
||||
}
|
||||
|
||||
@GetMapping("/tasks")
|
||||
fun getTasks(query: TaskQuery): Mono<Paginated<UiTask>> {
|
||||
return coordinator.getPagedTasks(query)
|
||||
}
|
||||
|
||||
@GetMapping("/tasks/{taskId}/reset")
|
||||
fun resetTask(@PathVariable taskId: UUID): Mono<ResponseEntity<ResetTaskResponse>> {
|
||||
return coordinator.resetTask(taskId)
|
||||
.map { ResponseEntity.ok(it) }
|
||||
.onErrorResume(WebClientResponseException::class.java) { ex ->
|
||||
if (ex.statusCode == HttpStatus.CONFLICT) {
|
||||
Mono.just(ResponseEntity.status(HttpStatus.CONFLICT).build())
|
||||
} else {
|
||||
Mono.error(ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/tasks/{taskId}/reset/force")
|
||||
fun resetTaskForce(@PathVariable taskId: UUID): Mono<ResponseEntity<ResetTaskResponse>> {
|
||||
return coordinator.resetTaskForced(taskId)
|
||||
.map { ResponseEntity.ok(it) }
|
||||
.onErrorResume(WebClientResponseException::class.java) { ex ->
|
||||
if (ex.statusCode == HttpStatus.CONFLICT) {
|
||||
Mono.just(ResponseEntity.status(HttpStatus.CONFLICT).build())
|
||||
} else {
|
||||
Mono.error(ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@GetMapping("/tasks/active")
|
||||
fun getActiveTasks() = coordinator.getActiveTasks()
|
||||
|
||||
@PostMapping("/operations/start")
|
||||
fun startProcess(@RequestBody req: StartProcessRequest): Mono<Map<String, String>> {
|
||||
val rewritten = req.copy(fileUri = mediaPathRewriteService.rewrite(req.fileUri))
|
||||
return coordinator.startProcess(rewritten)
|
||||
}
|
||||
|
||||
@GetMapping("/sse") fun events(): SseEmitter = hub.createEmitter()
|
||||
|
||||
@GetMapping("/status")
|
||||
fun getStatus(): SystemStatus {
|
||||
return statusService.status
|
||||
}
|
||||
|
||||
@GetMapping("/health")
|
||||
fun getHealth(): Mono<CoordinatorHealth> {
|
||||
return coordinator.getHealth()
|
||||
}
|
||||
|
||||
@GetMapping("/health/events")
|
||||
fun getHealthEventRate(): Mono<EventRate> {
|
||||
return coordinator.getEventRate()
|
||||
}
|
||||
|
||||
@GetMapping("/health/storage")
|
||||
fun getHealthStorage(): Mono<List<DiskInfo>> {
|
||||
return coordinator.getHealthStorage()
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
package no.iktdev.mediaprocessing.ui.dto
|
||||
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
|
||||
data class CoordinatorEventDto(
|
||||
val id: Long,
|
||||
val referenceId: UUID,
|
||||
val eventId: UUID,
|
||||
val event: String,
|
||||
val data: String,
|
||||
val persistedAt: Instant
|
||||
) {
|
||||
fun toUiEvent() = UiEvent(
|
||||
id = id,
|
||||
referenceId = referenceId,
|
||||
eventId = eventId,
|
||||
event = event,
|
||||
data = data,
|
||||
persistedAt = persistedAt
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
package no.iktdev.mediaprocessing.ui.dto
|
||||
|
||||
import java.time.LocalDateTime
|
||||
import java.util.*
|
||||
|
||||
data class CoordinatorTaskDto(
|
||||
val id: Long,
|
||||
val referenceId: UUID,
|
||||
val status: String,
|
||||
val taskId: UUID,
|
||||
val task: String,
|
||||
val data: String,
|
||||
val claimed: Boolean,
|
||||
val claimedBy: String?,
|
||||
val consumed: Boolean,
|
||||
val lastCheckIn: LocalDateTime?,
|
||||
val persistedAt: LocalDateTime,
|
||||
val abandoned: Boolean,
|
||||
) {
|
||||
}
|
||||
|
||||
fun CoordinatorTaskDto.toUiTask() = UiTask(
|
||||
id = id,
|
||||
referenceId = referenceId,
|
||||
status = status,
|
||||
taskId = taskId,
|
||||
task = task,
|
||||
data = data,
|
||||
claimed = claimed,
|
||||
claimedBy = claimedBy,
|
||||
consumed = consumed,
|
||||
lastCheckIn = lastCheckIn,
|
||||
persistedAt = persistedAt,
|
||||
abandoned = abandoned,
|
||||
)
|
||||
@ -0,0 +1,8 @@
|
||||
package no.iktdev.mediaprocessing.ui.dto
|
||||
|
||||
data class Paginated<T>(
|
||||
val items: List<T>,
|
||||
val page: Int,
|
||||
val size: Int,
|
||||
val total: Long,
|
||||
)
|
||||
@ -0,0 +1,6 @@
|
||||
package no.iktdev.mediaprocessing.ui.dto
|
||||
|
||||
data class SSEMessage(
|
||||
val name: String,
|
||||
val data: Any
|
||||
)
|
||||
@ -0,0 +1,14 @@
|
||||
package no.iktdev.mediaprocessing.ui.dto
|
||||
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
|
||||
data class UiEvent(
|
||||
val id: Long,
|
||||
val referenceId: UUID,
|
||||
val eventId: UUID,
|
||||
val event: String,
|
||||
val data: String,
|
||||
val persistedAt: Instant
|
||||
) {
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
package no.iktdev.mediaprocessing.ui.dto
|
||||
|
||||
import java.time.LocalDateTime
|
||||
import java.util.*
|
||||
|
||||
data class UiTask(
|
||||
val id: Long,
|
||||
val referenceId: UUID,
|
||||
val status: String,
|
||||
val taskId: UUID,
|
||||
val task: String,
|
||||
val data: String,
|
||||
val claimed: Boolean,
|
||||
val claimedBy: String?,
|
||||
val consumed: Boolean,
|
||||
val lastCheckIn: LocalDateTime?,
|
||||
val persistedAt: LocalDateTime,
|
||||
val abandoned: Boolean,
|
||||
|
||||
// Sanntidsfelter (kun fra SSE)
|
||||
val progress: Int? = null,
|
||||
val timeLeft: Double? = null,
|
||||
val speed: Double? = null,
|
||||
val elapsed: Double? = null,
|
||||
)
|
||||
@ -0,0 +1,65 @@
|
||||
package no.iktdev.mediaprocessing.ui.dto.file
|
||||
|
||||
enum class FileType {
|
||||
Folder,
|
||||
File
|
||||
}
|
||||
|
||||
sealed class IFile {
|
||||
abstract val name: String
|
||||
abstract val uri: String
|
||||
abstract val created: Long
|
||||
abstract val type: FileType
|
||||
abstract val actions: FileActions
|
||||
}
|
||||
|
||||
data class FileItem(
|
||||
override val name: String,
|
||||
override val uri: String,
|
||||
override val created: Long,
|
||||
val extension: String,
|
||||
override val actions: FileActions
|
||||
) : IFile() {
|
||||
override val type = FileType.File
|
||||
}
|
||||
|
||||
data class FolderItem(
|
||||
override val name: String,
|
||||
override val uri: String,
|
||||
override val created: Long,
|
||||
override val actions: FileActions = FileActions(emptyList(), listOf(
|
||||
FileAction(id = FileActionType.Delete, requiresConfirmation = true)
|
||||
)),
|
||||
) : IFile() {
|
||||
override val type = FileType.Folder
|
||||
}
|
||||
|
||||
data class FileActions(
|
||||
val mediaActions: List<MediaAction>,
|
||||
val fileActions: List<FileAction>
|
||||
)
|
||||
|
||||
data class MediaAction(
|
||||
val id: MediaActionType,
|
||||
val title: String = id.label
|
||||
) {
|
||||
}
|
||||
|
||||
enum class MediaActionType(val label: String) {
|
||||
All("All"),
|
||||
Encode("Encode"),
|
||||
ExtractSubtitles("Extract Subtitles"),
|
||||
ConvertSubtitle("Convert Subtitle"),
|
||||
}
|
||||
|
||||
enum class FileActionType(val label: String) {
|
||||
Open("Open"),
|
||||
Delete("Delete")
|
||||
}
|
||||
|
||||
data class FileAction(
|
||||
val id: FileActionType,
|
||||
val title: String = id.label,
|
||||
val requiresConfirmation: Boolean = false
|
||||
)
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
package no.iktdev.mediaprocessing.ui.dto.health
|
||||
|
||||
import java.time.Instant
|
||||
|
||||
data class CoordinatorHealth(
|
||||
val status: CoordinatorHealthStatus,
|
||||
val abandonedTasks: Int,
|
||||
val stalledTasks: Int,
|
||||
val activeTasks: Int,
|
||||
val queuedTasks: Int,
|
||||
val failedTasks: Int,
|
||||
val sequencesOnHold: Int,
|
||||
val lastActivity: Instant?,
|
||||
|
||||
// IDs for UI linking
|
||||
val abandonedTaskIds: List<String>,
|
||||
val stalledTaskIds: List<String>,
|
||||
val sequencesOnHoldIds: List<String>,
|
||||
val overdueSequenceIds: List<String>,
|
||||
|
||||
|
||||
// Detailed sequence info
|
||||
val overdueSequences: List<SequenceHealth>,
|
||||
|
||||
val details: Map<String, Any?>
|
||||
)
|
||||
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
package no.iktdev.mediaprocessing.ui.dto.health
|
||||
|
||||
enum class CoordinatorHealthStatus {
|
||||
HEALTHY,
|
||||
DEGRADED,
|
||||
UNHEALTHY
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package no.iktdev.mediaprocessing.ui.dto.health
|
||||
|
||||
data class DiskInfo(
|
||||
val mount: String,
|
||||
val device: String,
|
||||
val totalBytes: Long,
|
||||
val freeBytes: Long,
|
||||
val usedBytes: Long,
|
||||
val usedPercent: Double
|
||||
)
|
||||
@ -0,0 +1,19 @@
|
||||
package no.iktdev.mediaprocessing.ui.dto.health
|
||||
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
|
||||
data class SequenceHealth(
|
||||
val referenceId: String,
|
||||
|
||||
val age: Duration,
|
||||
val expected: Duration,
|
||||
val lastEventAt: Instant,
|
||||
val eventCount: Int,
|
||||
|
||||
// nye felter
|
||||
val startTime: Instant,
|
||||
val expectedFinishTime: Instant,
|
||||
val overdueDuration: Duration,
|
||||
val isOverdue: Boolean
|
||||
)
|
||||
@ -0,0 +1,6 @@
|
||||
package no.iktdev.mediaprocessing.ui.dto.rate
|
||||
|
||||
data class EventRate(
|
||||
val lastMinute: Int,
|
||||
val lastFiveMinutes: Int
|
||||
)
|
||||
@ -0,0 +1,6 @@
|
||||
package no.iktdev.mediaprocessing.ui.dto.requests
|
||||
|
||||
sealed interface ContinueResult {
|
||||
data object Success : ContinueResult
|
||||
data class Failure(val message: String) : ContinueResult
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package no.iktdev.mediaprocessing.ui.dto.requests
|
||||
|
||||
import no.iktdev.mediaprocessing.ui.dto.file.MediaActionType
|
||||
|
||||
data class StartProcessRequest(
|
||||
val fileUri: String,
|
||||
val mediaAction: MediaActionType
|
||||
) {
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
package no.iktdev.mediaprocessing.ui.dto.status
|
||||
|
||||
data class SystemStatus(
|
||||
var coordinatorRest: Boolean = false,
|
||||
var coordinatorSse: Boolean = false,
|
||||
var processer: Boolean = false,
|
||||
var converter: Boolean = false,
|
||||
var pyMetadata: Boolean = false,
|
||||
var pyWatcher: Boolean = false,
|
||||
var interval: Long = 0L,
|
||||
var timestamp: Long = 0L
|
||||
)
|
||||
@ -0,0 +1,261 @@
|
||||
package no.iktdev.mediaprocessing.ui.service
|
||||
|
||||
import mu.KotlinLogging
|
||||
import no.iktdev.mediaprocessing.shared.common.dto.*
|
||||
import no.iktdev.mediaprocessing.shared.common.dto.requests.StartProcessRequest
|
||||
import no.iktdev.mediaprocessing.shared.common.event_task_contract.events.OperationType
|
||||
import no.iktdev.mediaprocessing.shared.common.model.ProgressUpdate
|
||||
import no.iktdev.mediaprocessing.ui.dto.*
|
||||
import no.iktdev.mediaprocessing.ui.dto.Paginated
|
||||
import no.iktdev.mediaprocessing.ui.dto.file.MediaActionType
|
||||
import no.iktdev.mediaprocessing.ui.dto.health.CoordinatorHealth
|
||||
import no.iktdev.mediaprocessing.ui.dto.health.DiskInfo
|
||||
import no.iktdev.mediaprocessing.ui.dto.rate.EventRate
|
||||
import no.iktdev.mediaprocessing.ui.dto.requests.ContinueResult
|
||||
import org.springframework.core.ParameterizedTypeReference
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.http.codec.ServerSentEvent
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.web.reactive.function.client.WebClient
|
||||
import reactor.core.publisher.Flux
|
||||
import reactor.core.publisher.Mono
|
||||
import reactor.util.retry.Retry
|
||||
import java.time.Duration
|
||||
import java.util.*
|
||||
|
||||
@Service
|
||||
class CoordinatorClient(
|
||||
private val coordinatorWebClient: WebClient,
|
||||
) {
|
||||
val log = KotlinLogging.logger {}
|
||||
|
||||
fun getHealth(): Mono<CoordinatorHealth> =
|
||||
coordinatorWebClient.get()
|
||||
.uri("/health")
|
||||
.retrieve()
|
||||
.bodyToMono(object : ParameterizedTypeReference<CoordinatorHealth>() {})
|
||||
|
||||
fun getEventRate(): Mono<EventRate> =
|
||||
coordinatorWebClient.get()
|
||||
.uri("/health/events")
|
||||
.retrieve()
|
||||
.bodyToMono(object : ParameterizedTypeReference<EventRate>() {})
|
||||
|
||||
fun getHealthStorage(): Mono<List<DiskInfo>> =
|
||||
coordinatorWebClient.get()
|
||||
.uri("/health/storage")
|
||||
.retrieve()
|
||||
.bodyToMono(object : ParameterizedTypeReference<List<DiskInfo>>() {})
|
||||
|
||||
fun getEventSequence(
|
||||
referenceId: UUID,
|
||||
beforeEventId: UUID? = null,
|
||||
afterEventId: UUID? = null,
|
||||
limit: Int = 50
|
||||
): Mono<List<SequenceEvent>> =
|
||||
coordinatorWebClient.get()
|
||||
.uri { uri ->
|
||||
uri.path("/events")
|
||||
.queryParam("referenceId", referenceId)
|
||||
.apply {
|
||||
beforeEventId?.let { queryParam("beforeEventId", it) }
|
||||
afterEventId?.let { queryParam("afterEventId", it) }
|
||||
}
|
||||
.queryParam("limit", limit)
|
||||
.build()
|
||||
}
|
||||
.retrieve()
|
||||
.bodyToMono(object : ParameterizedTypeReference<List<SequenceEvent>>() {})
|
||||
|
||||
|
||||
|
||||
fun getPagedEvents(eventsQuery: EventQuery): Mono<Paginated<UiEvent>> =
|
||||
coordinatorWebClient.get()
|
||||
.uri { uri ->
|
||||
uri.path("/events")
|
||||
.queryParams(eventsQuery.toQueryParams())
|
||||
.build()
|
||||
}
|
||||
.retrieve()
|
||||
.bodyToMono(object : ParameterizedTypeReference<Paginated<CoordinatorEventDto>>() {})
|
||||
.map { paginated ->
|
||||
Paginated(
|
||||
items = paginated.items.map { it.toUiEvent() },
|
||||
page = paginated.page,
|
||||
size = paginated.size,
|
||||
total = paginated.total
|
||||
)
|
||||
}
|
||||
|
||||
fun getEffectiveHistory(referenceId: UUID): Mono<List<UiEvent>> =
|
||||
coordinatorWebClient.get()
|
||||
.uri("/events/history/${referenceId}/effective")
|
||||
.retrieve()
|
||||
.bodyToMono(object : ParameterizedTypeReference<List<CoordinatorEventDto>>() {})
|
||||
.map { it.map { x -> x.toUiEvent() } }
|
||||
|
||||
|
||||
fun getPagedTasks(taskQuery: TaskQuery): Mono<Paginated<UiTask>> =
|
||||
coordinatorWebClient.get()
|
||||
.uri { uri ->
|
||||
uri.path("/tasks")
|
||||
.queryParams(taskQuery.toQueryParams())
|
||||
.build()
|
||||
}
|
||||
.retrieve()
|
||||
.bodyToMono(object : ParameterizedTypeReference<Paginated<CoordinatorTaskDto>>() {})
|
||||
.map { paginatedDto ->
|
||||
Paginated(
|
||||
items = paginatedDto.items.map { it.toUiTask() },
|
||||
page = paginatedDto.page,
|
||||
size = paginatedDto.size,
|
||||
total = paginatedDto.total
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
fun getActiveTasks(): Mono<List<UiTask>> =
|
||||
coordinatorWebClient.get()
|
||||
.uri("/tasks/active")
|
||||
.retrieve()
|
||||
.bodyToMono(object : ParameterizedTypeReference<List<CoordinatorTaskDto>>() {})
|
||||
.map { it.map { x -> x.toUiTask() } }
|
||||
|
||||
fun resetTask(taskId: UUID): Mono<ResetTaskResponse> =
|
||||
coordinatorWebClient.get()
|
||||
.uri("/tasks/${taskId}/reset")
|
||||
.retrieve()
|
||||
.bodyToMono(object : ParameterizedTypeReference<ResetTaskResponse>() {})
|
||||
|
||||
fun resetTaskForced(taskId: UUID): Mono<ResetTaskResponse> =
|
||||
coordinatorWebClient.get()
|
||||
.uri("/tasks/${taskId}/reset/force")
|
||||
.retrieve()
|
||||
.bodyToMono(object : ParameterizedTypeReference<ResetTaskResponse>() {})
|
||||
|
||||
|
||||
fun connectToSse(
|
||||
onConnected: () -> Unit,
|
||||
onDisconnected: () -> Unit,
|
||||
onReconnecting: () -> Unit
|
||||
): Flux<ServerSentEvent<String>> {
|
||||
|
||||
return coordinatorWebClient.get()
|
||||
.uri("/internal/sse")
|
||||
.accept(MediaType.TEXT_EVENT_STREAM)
|
||||
.retrieve()
|
||||
.bodyToFlux(object : ParameterizedTypeReference<ServerSentEvent<String>>() {})
|
||||
.doOnSubscribe {
|
||||
onConnected()
|
||||
}
|
||||
.doOnError { ex ->
|
||||
onDisconnected()
|
||||
log.warn(ex) { "SSE connection lost" }
|
||||
}
|
||||
.doOnCancel {
|
||||
log.info { "SSE subscription cancelled" }
|
||||
}
|
||||
.retryWhen(
|
||||
Retry.backoff(Long.MAX_VALUE, Duration.ofSeconds(1))
|
||||
.doBeforeRetry {
|
||||
onReconnecting()
|
||||
log.info { "Reconnecting to SSE..." }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
fun getProgress(): Mono<List<ProgressUpdate>> =
|
||||
coordinatorWebClient.get()
|
||||
.uri("/internal/progress")
|
||||
.retrieve()
|
||||
.bodyToMono(object : ParameterizedTypeReference<List<ProgressUpdate>>() {})
|
||||
|
||||
fun startProcess(req: no.iktdev.mediaprocessing.ui.dto.requests.StartProcessRequest): Mono<Map<String, String>> {
|
||||
val operations: Set<OperationType> = when (req.mediaAction) {
|
||||
MediaActionType.All -> setOf(
|
||||
OperationType.Encode,
|
||||
OperationType.ExtractSubtitles,
|
||||
OperationType.ConvertSubtitles
|
||||
)
|
||||
MediaActionType.Encode -> setOf(OperationType.Encode)
|
||||
MediaActionType.ExtractSubtitles -> setOf(OperationType.ExtractSubtitles)
|
||||
MediaActionType.ConvertSubtitle -> setOf(OperationType.ConvertSubtitles)
|
||||
}
|
||||
|
||||
|
||||
val coordinatorRequest = StartProcessRequest(
|
||||
fileUri = req.fileUri,
|
||||
operationTypes = operations
|
||||
)
|
||||
log.info { "Starting process for fileUri=${req.fileUri} with operations=$operations" }
|
||||
return coordinatorWebClient.post()
|
||||
.uri("/operations/start")
|
||||
.bodyValue(coordinatorRequest)
|
||||
.retrieve()
|
||||
.bodyToMono(object : ParameterizedTypeReference<Map<String, String>>() {})
|
||||
}
|
||||
|
||||
fun getActiveSequences(): Mono<List<SequenceSummary>> =
|
||||
coordinatorWebClient.get()
|
||||
.uri("/sequences/active")
|
||||
.retrieve()
|
||||
.bodyToMono(object : ParameterizedTypeReference<List<SequenceSummary>>() {})
|
||||
|
||||
fun getRecentSequences(limit: Int = 15): Mono<List<SequenceSummary>> =
|
||||
coordinatorWebClient.get()
|
||||
.uri("/sequences/recent?limit=$limit")
|
||||
.retrieve()
|
||||
.bodyToMono(object : ParameterizedTypeReference<List<SequenceSummary>>() {})
|
||||
|
||||
fun continueSequence(referenceId: UUID): ContinueResult {
|
||||
return try {
|
||||
coordinatorWebClient.post()
|
||||
.uri { it.path("/sequences/{id}/continue").build(referenceId.toString()) }
|
||||
.retrieve()
|
||||
.onStatus({ it.is4xxClientError }) { resp ->
|
||||
resp.bodyToMono(String::class.java)
|
||||
.flatMap { msg -> Mono.error(RuntimeException("Client error: $msg")) }
|
||||
}
|
||||
.onStatus({ it.is5xxServerError }) { resp ->
|
||||
resp.bodyToMono(String::class.java)
|
||||
.flatMap { msg -> Mono.error(RuntimeException("Server error: $msg")) }
|
||||
}
|
||||
.bodyToMono(Void::class.java)
|
||||
.block()
|
||||
|
||||
ContinueResult.Success
|
||||
|
||||
} catch (ex: Exception) {
|
||||
ContinueResult.Failure(ex.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
fun deleteSequence(referenceId: UUID): ContinueResult {
|
||||
return try {
|
||||
coordinatorWebClient.post()
|
||||
.uri { it.path("/sequences/{id}/delete").build(referenceId.toString()) }
|
||||
.retrieve()
|
||||
.onStatus({ it.is4xxClientError }) { resp ->
|
||||
resp.bodyToMono(String::class.java)
|
||||
.flatMap { msg -> Mono.error(RuntimeException("Client error: $msg")) }
|
||||
}
|
||||
.onStatus({ it.is5xxServerError }) { resp ->
|
||||
resp.bodyToMono(String::class.java)
|
||||
.flatMap { msg -> Mono.error(RuntimeException("Server error: $msg")) }
|
||||
}
|
||||
.bodyToMono(Void::class.java)
|
||||
.block()
|
||||
|
||||
ContinueResult.Success
|
||||
|
||||
} catch (ex: Exception) {
|
||||
ContinueResult.Failure(ex.message ?: "Unknown error")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
@ -0,0 +1,91 @@
|
||||
package no.iktdev.mediaprocessing.ui.service
|
||||
|
||||
import no.iktdev.mediaprocessing.shared.common.notExist
|
||||
import no.iktdev.mediaprocessing.ui.MediaConfig
|
||||
import no.iktdev.mediaprocessing.ui.dto.file.*
|
||||
import org.springframework.stereotype.Service
|
||||
import java.io.File
|
||||
|
||||
@Service
|
||||
class ExplorerService(
|
||||
val mediaConfig: MediaConfig
|
||||
) {
|
||||
|
||||
fun listHome(): List<IFile> =
|
||||
listAt(mediaConfig.incoming)
|
||||
|
||||
fun listAt(path: String): List<IFile> {
|
||||
val dir = File(path)
|
||||
|
||||
if (!dir.exists() || !dir.isDirectory) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
return dir.listFiles()
|
||||
?.map { file ->
|
||||
file.toFileInfo()
|
||||
}
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
|
||||
fun pathToFile(path: String): IFile? {
|
||||
val file = File(path)
|
||||
if (file.notExist())
|
||||
return null
|
||||
return file.toFileInfo()
|
||||
}
|
||||
|
||||
fun File.toFileInfo(): IFile {
|
||||
val file = this
|
||||
return if (file.isDirectory) {
|
||||
FolderItem(
|
||||
name = file.name,
|
||||
uri = file.absolutePath,
|
||||
created = file.lastModified()
|
||||
)
|
||||
} else {
|
||||
FileItem(
|
||||
name = file.name,
|
||||
uri = file.absolutePath,
|
||||
created = file.lastModified(),
|
||||
extension = file.extension,
|
||||
actions = FileActions(mediaActions = getMediaActionsForFile(file), fileActions = listOf(
|
||||
FileAction(id = FileActionType.Delete, requiresConfirmation = true)
|
||||
))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun getMediaActionsForFile(file: File): List<MediaAction> {
|
||||
val ext = file.extension.lowercase()
|
||||
|
||||
// 1. Subtitle files → Convert
|
||||
val subtitleExt = setOf("srt", "ass", "smi", "vtt")
|
||||
if (ext in subtitleExt) {
|
||||
return listOf(MediaAction(MediaActionType.ConvertSubtitle))
|
||||
}
|
||||
|
||||
// 2. Video containers that support embedded subtitles
|
||||
val subtitleVideoContainers = setOf("mkv", "mp4", "mov", "webm")
|
||||
if (ext in subtitleVideoContainers) {
|
||||
return listOf(
|
||||
MediaAction(MediaActionType.All),
|
||||
MediaAction(MediaActionType.Encode),
|
||||
MediaAction(MediaActionType.ExtractSubtitles)
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Video containers that do NOT support subtitles
|
||||
val nonSubtitleVideoContainers = setOf("avi", "mpeg", "mpg", "ts", "m2ts", "wmv", "flv")
|
||||
if (ext in nonSubtitleVideoContainers) {
|
||||
return listOf(MediaAction(MediaActionType.Encode))
|
||||
}
|
||||
|
||||
// 4. Everything else → no media actions
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
package no.iktdev.mediaprocessing.ui.service
|
||||
|
||||
import mu.KotlinLogging
|
||||
import no.iktdev.mediaprocessing.ui.MediaConfig
|
||||
import org.springframework.core.env.Environment
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
@Service
|
||||
class MediaPathRewriteService(
|
||||
private val cfg: MediaConfig,
|
||||
private val env: Environment
|
||||
) {
|
||||
|
||||
private val log = KotlinLogging.logger {}
|
||||
|
||||
init {
|
||||
val active = env.activeProfiles.any { it == "dev" || it == "local" }
|
||||
|
||||
if (active) {
|
||||
log.warn { "MediaPathRewriteService is ACTIVE (profiles: ${env.activeProfiles.joinToString()})" }
|
||||
|
||||
if (cfg.cacheRewrite != null)
|
||||
log.warn { " - cache rewrite: ${cfg.cache} → ${cfg.cacheRewrite!!.to}" }
|
||||
|
||||
if (cfg.outgoingRewrite != null)
|
||||
log.warn { " - outgoing rewrite: ${cfg.outgoing} → ${cfg.outgoingRewrite!!.to}" }
|
||||
|
||||
if (cfg.incomingRewrite != null)
|
||||
log.warn { " - incoming rewrite: ${cfg.incoming} → ${cfg.incomingRewrite!!.to}" }
|
||||
|
||||
} else {
|
||||
log.info { "MediaPathRewriteService is INACTIVE (profiles: ${env.activeProfiles.joinToString()})" }
|
||||
}
|
||||
}
|
||||
|
||||
fun rewrite(path: String): String {
|
||||
// Only active in dev/local
|
||||
if (!env.activeProfiles.any { it == "dev" || it == "local" }) {
|
||||
return path
|
||||
}
|
||||
|
||||
val rewritten = when {
|
||||
path.startsWith(cfg.cache) && cfg.cacheRewrite != null ->
|
||||
path.replaceFirst(cfg.cache, cfg.cacheRewrite!!.to)
|
||||
|
||||
path.startsWith(cfg.outgoing) && cfg.outgoingRewrite != null ->
|
||||
path.replaceFirst(cfg.outgoing, cfg.outgoingRewrite!!.to)
|
||||
|
||||
path.startsWith(cfg.incoming) && cfg.incomingRewrite != null ->
|
||||
path.replaceFirst(cfg.incoming, cfg.incomingRewrite!!.to)
|
||||
|
||||
else -> path
|
||||
}
|
||||
|
||||
if (rewritten != path) {
|
||||
log.info { "Rewriting media path: '$path' → '$rewritten'" }
|
||||
}
|
||||
|
||||
return rewritten
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,67 @@
|
||||
package no.iktdev.mediaprocessing.ui.service
|
||||
|
||||
import jakarta.annotation.PostConstruct
|
||||
import no.iktdev.mediaprocessing.ui.AppConfig
|
||||
import no.iktdev.mediaprocessing.ui.AppsConfig
|
||||
import no.iktdev.mediaprocessing.ui.UiSseHub
|
||||
import no.iktdev.mediaprocessing.ui.dto.status.SystemStatus
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.web.reactive.function.client.WebClient
|
||||
import java.time.Duration
|
||||
|
||||
@Service
|
||||
class StatusService(
|
||||
private val apps: AppsConfig,
|
||||
private val webClient: WebClient,
|
||||
private val hub: UiSseHub
|
||||
) {
|
||||
var status: SystemStatus = SystemStatus()
|
||||
private val intervalMs = 5000L // samme som @Scheduled(fixedDelay = 5000)
|
||||
|
||||
@Scheduled(fixedDelay = 5000)
|
||||
fun checkServices() {
|
||||
status.interval = intervalMs
|
||||
status.processer = check(apps.processer)
|
||||
status.converter = check(apps.converter)
|
||||
status.pyMetadata = check(apps.metadata)
|
||||
status.pyWatcher = check(apps.watcher)
|
||||
status.timestamp = System.currentTimeMillis()
|
||||
|
||||
// coordinator REST sjekkes også her
|
||||
status.coordinatorRest = check(apps.coordinator)
|
||||
hub.broadcast(status, "healthStatus")
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
fun init() {
|
||||
hub.registerListener(object : UiSseHub.SSEStateListener {
|
||||
override fun onConnected() {
|
||||
status.coordinatorSse = true
|
||||
}
|
||||
|
||||
override fun onReconnecting() {
|
||||
status.coordinatorSse = false
|
||||
}
|
||||
|
||||
override fun onDisconnected() {
|
||||
status.coordinatorSse = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
private fun check(app: AppConfig): Boolean =
|
||||
try {
|
||||
webClient.get()
|
||||
.uri(app.address + app.health)
|
||||
.retrieve()
|
||||
.bodyToMono(String::class.java)
|
||||
.timeout(Duration.ofSeconds(2))
|
||||
.map { true }
|
||||
.onErrorReturn(false)
|
||||
.block()!!
|
||||
} catch (ex: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
package no.iktdev.mediaprocessing.ui
|
||||
|
||||
import no.iktdev.mediaprocessing.ui.dto.UiTask
|
||||
|
||||
52
apps/ui/src/main/resources/application-dev.yml
Normal file
52
apps/ui/src/main/resources/application-dev.yml
Normal file
@ -0,0 +1,52 @@
|
||||
spring:
|
||||
output:
|
||||
ansi:
|
||||
enabled: always
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health
|
||||
endpoint:
|
||||
health:
|
||||
show-details: always
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: INFO
|
||||
org.apache.kafka: INFO
|
||||
Exposed: OFF
|
||||
org.springframework.web.socket.config.WebSocketMessageBrokerStats: INFO
|
||||
|
||||
media:
|
||||
cache: /media/remote/mediaprocessingCache
|
||||
cache-rewrite:
|
||||
to: /src/cache
|
||||
outgoing: /media/remote/streamit
|
||||
outgoing-rewrite:
|
||||
to: /src/output
|
||||
incoming: /media/remote/torrent
|
||||
incoming-rewrite:
|
||||
to: /src/input
|
||||
|
||||
streamit:
|
||||
address: http://streamit.service
|
||||
|
||||
mediaprocessing:
|
||||
apps:
|
||||
coordinator:
|
||||
address: http://192.168.2.250:6080
|
||||
health: /actuator/health
|
||||
processer:
|
||||
address: http://192.168.2.250:6082
|
||||
health: /actuator/health
|
||||
converter:
|
||||
address: http://192.168.2.250:6081
|
||||
health: /actuator/health
|
||||
metadata:
|
||||
address: http://192.168.2.250:6083
|
||||
health: /health
|
||||
watcher:
|
||||
address: http://192.168.2.250:6084
|
||||
health: /health
|
||||
@ -1,4 +0,0 @@
|
||||
spring.output.ansi.enabled=always
|
||||
logging.level.org.apache.kafka=INFO
|
||||
logging.level.root=INFO
|
||||
logging.level.org.springframework.web.socket.config.WebSocketMessageBrokerStats = INFO
|
||||
46
apps/ui/src/main/resources/application.yml
Normal file
46
apps/ui/src/main/resources/application.yml
Normal file
@ -0,0 +1,46 @@
|
||||
spring:
|
||||
output:
|
||||
ansi:
|
||||
enabled: always
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health
|
||||
endpoint:
|
||||
health:
|
||||
show-details: always
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: INFO
|
||||
org.apache.kafka: INFO
|
||||
Exposed: OFF
|
||||
org.springframework.web.socket.config.WebSocketMessageBrokerStats: INFO
|
||||
|
||||
media:
|
||||
cache: /src/cache
|
||||
outgoing: /src/output
|
||||
incoming: /src/input
|
||||
|
||||
streamit:
|
||||
address: http://streamit.service
|
||||
|
||||
mediaprocessing:
|
||||
apps:
|
||||
coordinator:
|
||||
address: http://coordinator:8080
|
||||
health: /actuator/health
|
||||
processer:
|
||||
address: http://processor:8080
|
||||
health: /actuator/health
|
||||
converter:
|
||||
address: http://converter:8080
|
||||
health: /actuator/health
|
||||
metadata:
|
||||
address: http://py-metadata:8080
|
||||
health: /health
|
||||
watcher:
|
||||
address: http://py-watcher:8080
|
||||
health: /health
|
||||
41
apps/ui/web/.gitignore
vendored
41
apps/ui/web/.gitignore
vendored
@ -1,23 +1,24 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
@ -1,46 +1,73 @@
|
||||
# Getting Started with Create React App
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
## Available Scripts
|
||||
Currently, two official plugins are available:
|
||||
|
||||
In the project directory, you can run:
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
### `npm start`
|
||||
## React Compiler
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
The page will reload if you make edits.\
|
||||
You will also see any lint errors in the console.
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
### `npm test`
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
### `npm run build`
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
### `npm run eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||
|
||||
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
23
apps/ui/web/eslint.config.js
Normal file
23
apps/ui/web/eslint.config.js
Normal file
@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
apps/ui/web/index.html
Normal file
13
apps/ui/web/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>web</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
33391
apps/ui/web/package-lock.json
generated
33391
apps/ui/web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,66 +1,37 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.14.9",
|
||||
"@mui/material": "^5.14.10",
|
||||
"@mui/x-data-grid": "^6.15.0",
|
||||
"@reduxjs/toolkit": "^1.9.5",
|
||||
"@stomp/stompjs": "^7.0.0",
|
||||
"@testing-library/jest-dom": "^5.16.5",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/node": "^16.18.38",
|
||||
"@types/react": "^18.2.15",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@types/styled-components": "^5.1.27",
|
||||
"react": "^18.2.0",
|
||||
"react-d3-tree": "^3.6.5",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "^8.1.1",
|
||||
"react-router-dom": "^6.15.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"react-stomp": "^5.1.0",
|
||||
"react-stomp-hooks": "^2.1.0",
|
||||
"react-tree-graph": "^8.0.2",
|
||||
"react-use-websocket": "^4.4.0",
|
||||
"redux": "^4.2.1",
|
||||
"sockjs-client": "^1.6.1",
|
||||
"stompjs": "^2.3.3",
|
||||
"styled-components": "^6.0.8",
|
||||
"typescript": "^4.9.5",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^7.3.7",
|
||||
"@mui/material": "^7.3.7",
|
||||
"@tanstack/react-query": "^5.90.16",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.11.0",
|
||||
"react-toastify": "^11.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/sockjs-client": "^1.5.1",
|
||||
"@types/stompjs": "^2.3.5"
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.8 KiB |
@ -1,43 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>React App</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.4 KiB |
@ -1,25 +0,0 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
1
apps/ui/web/public/vite.svg
Normal file
1
apps/ui/web/public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@ -1,21 +0,0 @@
|
||||
|
||||
.contextmenu {
|
||||
display: block;
|
||||
border: black 1px solid;
|
||||
border-radius: 5px;
|
||||
|
||||
position: absolute;
|
||||
|
||||
}
|
||||
|
||||
.contextMenuItem {
|
||||
cursor: pointer;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
padding-left: 25px;
|
||||
padding-right: 25px;
|
||||
}
|
||||
|
||||
.contextMenuItem:hover {
|
||||
background-color: black;
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
@ -1,125 +1,90 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import logo from './logo.svg';
|
||||
import './App.css';
|
||||
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';
|
||||
import { useWsSubscription } from './app/ws/subscriptions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useStompClient, useSubscription } from 'react-stomp-hooks';
|
||||
import { updateItems } from './app/store/composed-slice';
|
||||
import ExplorePage from './app/page/ExplorePage';
|
||||
import { ThemeProvider } from '@mui/material';
|
||||
import theme from './theme';
|
||||
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 DashboardIcon from '@mui/icons-material/Dashboard';
|
||||
import GraphicEqIcon from '@mui/icons-material/GraphicEq';
|
||||
import HomeRepairServiceIcon from '@mui/icons-material/HomeRepairService';
|
||||
import InboxIcon from '@mui/icons-material/Inbox';
|
||||
import InputIcon from '@mui/icons-material/Input';
|
||||
import NotStartedIcon from '@mui/icons-material/NotStarted';
|
||||
import EventsPage from './app/page/EventsPage';
|
||||
import TableChartIcon from '@mui/icons-material/TableChart';
|
||||
import { useState } from 'react'
|
||||
import './App.css'
|
||||
|
||||
function App() {
|
||||
const client = useStompClient();
|
||||
const dispatch = useDispatch();
|
||||
import { Box } from "@mui/material"
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
||||
import { ToastContainer } from 'react-toastify'
|
||||
import { Sidebar } from "./components/Sidebar"
|
||||
import { TopBar } from "./components/TopBar"
|
||||
import { HealthProvider } from './context/HealthProvider'
|
||||
import DashboardPage from './pages/DasboardPage'
|
||||
import EventsPage from './pages/EventsPage'
|
||||
import EventsSequencePage from './pages/EventsSequencePage'
|
||||
import FilesPage from './pages/FilesPage'
|
||||
import HealthPage from './pages/HealthPage'
|
||||
import { SequencePage } from './pages/SequencePage'
|
||||
import TasksPage from './pages/TasksPage'
|
||||
|
||||
useWsSubscription<Array<EventDataObject>>("/topic/event/items", (response) => {
|
||||
dispatch(updateItems(response))
|
||||
});
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
// Kjør din funksjon her når komponenten lastes inn for første gang
|
||||
// Sjekk om cursor er null
|
||||
|
||||
// Kjør din funksjon her når cursor er null og client ikke er null
|
||||
client?.publish({
|
||||
destination: "/app/items",
|
||||
body: undefined
|
||||
})
|
||||
|
||||
// Alternativt, du kan dispatche en Redux handling her
|
||||
// dispatch(fetchDataAction()); // Eksempel på å dispatche en handling
|
||||
|
||||
}, [client, dispatch]);
|
||||
|
||||
const iconHeight: SxProps<Theme> = {
|
||||
height: 50,
|
||||
width: 50
|
||||
interface AppLayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function AppLayout({ children }: AppLayoutProps) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const drawerWidth = sidebarOpen ? 260 : 64
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<Box sx={{
|
||||
height: 70,
|
||||
<Box
|
||||
sx={{
|
||||
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 = "/processer"} sx={{
|
||||
...iconHeight
|
||||
}}>
|
||||
<GraphicEqIcon />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => window.location.href = "/eventsflow"} 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
|
||||
}}>
|
||||
<TableChartIcon />
|
||||
</IconButton>
|
||||
width: "100vw", // ← kritisk
|
||||
height: "100vh", // ← kritisk
|
||||
overflow: "hidden" // ← hindrer scroll her
|
||||
}}
|
||||
>
|
||||
<TopBar onToggleSidebar={() => setSidebarOpen(prev => !prev)} />
|
||||
|
||||
<Sidebar open={sidebarOpen} />
|
||||
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
ml: `${drawerWidth}px`,
|
||||
mt: "64px",
|
||||
width: `calc(100vw - ${drawerWidth}px)`, // ← kritisk
|
||||
height: `calc(100vh - 56px)`, // ← kritisk
|
||||
overflow: "hidden", // ← main skal ikke scrolle
|
||||
position: "relative" // ← for sticky i child
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
<Box sx={{
|
||||
display: "block",
|
||||
maxHeight: window.screen.height - 70,
|
||||
height: window.screen.height - 70,
|
||||
width: "100vw",
|
||||
maxWidth: "100vw"
|
||||
}}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path='/processer' element={<EventsPage />} />
|
||||
<Route path='/unprocessed' element={<UnprocessedFilesPage />} />
|
||||
<Route path='/files' element={<ExplorePage />} />
|
||||
<Route path='/eventsflow' element={<EventsChainPage />} />
|
||||
<Route path='/' element={<LaunchPage />} />
|
||||
</Routes>
|
||||
<Footer />
|
||||
</BrowserRouter>
|
||||
</Box>
|
||||
</ThemeProvider>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<HealthProvider>
|
||||
<AppLayout>
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path='/sequences' element={<SequencePage />} />
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/health" element={<HealthPage />} />
|
||||
<Route path="/tasks" element={<TasksPage />} />
|
||||
<Route path="/events" element={<EventsPage />} />
|
||||
<Route path="/events/sequence/:referenceId" element={<EventsSequencePage />} />
|
||||
</Routes>
|
||||
<ToastContainer
|
||||
position='bottom-left'
|
||||
autoClose={3000}
|
||||
hideProgressBar={true}
|
||||
newestOnTop={true}
|
||||
closeOnClick
|
||||
pauseOnHover
|
||||
theme='dark'
|
||||
/>
|
||||
</AppLayout>
|
||||
</HealthProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export default App
|
||||
|
||||
104
apps/ui/web/src/api/client.ts
Normal file
104
apps/ui/web/src/api/client.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import type { SSEMessage } from "../types/SSEMessage"
|
||||
|
||||
export async function apiGet<T>(
|
||||
path: string,
|
||||
opts?: {
|
||||
onError?: (status: number, body: any) => void
|
||||
}
|
||||
): Promise<T> {
|
||||
const res = await fetch(`/api${path}`, {
|
||||
headers: {
|
||||
Accept: "application/json"
|
||||
}
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const status = res.status
|
||||
let body: any = null
|
||||
|
||||
try {
|
||||
body = await res.json()
|
||||
} catch {
|
||||
body = await res.text().catch(() => null)
|
||||
}
|
||||
|
||||
// If user provided custom error handler → use it
|
||||
if (opts?.onError) {
|
||||
opts.onError(status, body)
|
||||
// Return a never-resolving promise so caller doesn't continue
|
||||
return Promise.reject({ status, body })
|
||||
}
|
||||
|
||||
// Default behavior: throw a normal error
|
||||
const error: any = new Error(`GET ${path} failed with ${status}`)
|
||||
error.status = status
|
||||
error.body = body
|
||||
throw error
|
||||
}
|
||||
|
||||
return res.json()
|
||||
}
|
||||
|
||||
|
||||
export async function apiPost<TRequest, TResponse>(path: string, body: TRequest): Promise<TResponse> {
|
||||
const res = await fetch(`/api${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "*/*" // ← viktig: ikke tving JSON
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
// prøv å lese tekst hvis mulig
|
||||
const text = await res.text().catch(() => null)
|
||||
throw new Error(text || `POST ${path} failed with ${res.status}`)
|
||||
}
|
||||
|
||||
// sjekk content-type
|
||||
const contentType = res.headers.get("content-type") ?? ""
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// hvis det ikke er JSON → returner tekst
|
||||
const text = await res.text()
|
||||
return text as unknown as TResponse
|
||||
}
|
||||
|
||||
export function apiSse(
|
||||
onEvent: (eventName: string, data: any) => void,
|
||||
onError?: (err: any) => void
|
||||
): EventSource {
|
||||
const es = new EventSource("/api/sse") // hardkodet
|
||||
|
||||
es.onmessage = (event) => {
|
||||
const message: SSEMessage = JSON.parse(event.data)
|
||||
onEvent(message.name, message.data)
|
||||
}
|
||||
|
||||
es.onerror = (err) => {
|
||||
if (onError) onError(err)
|
||||
}
|
||||
|
||||
return es
|
||||
}
|
||||
|
||||
|
||||
export function buildQuery(params: Record<string, any>): string {
|
||||
const search = new URLSearchParams()
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined || value === null) continue
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(v => search.append(key, String(v)))
|
||||
} else {
|
||||
search.append(key, String(value))
|
||||
}
|
||||
}
|
||||
|
||||
return search.toString()
|
||||
}
|
||||
11
apps/ui/web/src/api/events.ts
Normal file
11
apps/ui/web/src/api/events.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import type { EventQuery, PagedUiEvent, UiEvent } from "../types/backendTypes";
|
||||
import { apiGet, buildQuery } from "./client";
|
||||
|
||||
export function getEvents(query: EventQuery) {
|
||||
const qs = buildQuery(query);
|
||||
return apiGet<PagedUiEvent>(`/events?${qs}`)
|
||||
}
|
||||
|
||||
export function getEffectiveEventsHistory(referenceId: string) {
|
||||
return apiGet<UiEvent[]>(`/events/history/${referenceId}/effective`)
|
||||
}
|
||||
10
apps/ui/web/src/api/files.ts
Normal file
10
apps/ui/web/src/api/files.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import type { IFile } from "../types/files"
|
||||
import { apiGet } from "./client"
|
||||
|
||||
export function apiListHome() {
|
||||
return apiGet<IFile[]>("/files/home")
|
||||
}
|
||||
|
||||
export function apiExplore(path: string) {
|
||||
return apiGet<IFile[]>(`/files/explore?path=${encodeURIComponent(path)}`)
|
||||
}
|
||||
6
apps/ui/web/src/api/health.ts
Normal file
6
apps/ui/web/src/api/health.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import type { CoordinatorHealth } from "../types/backendTypes";
|
||||
import { apiGet } from "./client";
|
||||
|
||||
export function getCoordinatorHealth() {
|
||||
return apiGet<CoordinatorHealth>("/health")
|
||||
}
|
||||
9
apps/ui/web/src/api/media.ts
Normal file
9
apps/ui/web/src/api/media.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import type { StartProcessRequest } from "../types/files"
|
||||
import { apiPost } from "./client"
|
||||
|
||||
export function startProcess(req: StartProcessRequest) {
|
||||
return apiPost<StartProcessRequest, Record<string, string>>(
|
||||
"/operations/start",
|
||||
req
|
||||
)
|
||||
}
|
||||
24
apps/ui/web/src/api/sequence.ts
Normal file
24
apps/ui/web/src/api/sequence.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import type { SequenceSummary } from "../types/backendTypes"
|
||||
import { apiGet, apiPost } from "./client"
|
||||
|
||||
export function getActiveSequences() {
|
||||
return apiGet<SequenceSummary[]>("/sequences/active")
|
||||
}
|
||||
|
||||
export function getRecentSequences(limit = 15) {
|
||||
return apiGet<SequenceSummary[]>(`/sequences/recent?limit=${limit}`)
|
||||
}
|
||||
|
||||
export async function continueSequence(referenceId: string) {
|
||||
try {
|
||||
const res = await apiPost(`/sequences/${referenceId}/continue`, {})
|
||||
|
||||
// Hvis backend returnerer 200 OK → ferdig
|
||||
return
|
||||
|
||||
} catch (err: any) {
|
||||
console.log(err)
|
||||
// Hvis backend returnerer 4xx/5xx → kast feilen videre
|
||||
throw new Error(err?.response?.data ?? "Unknown error")
|
||||
}
|
||||
}
|
||||
19
apps/ui/web/src/api/tasks.ts
Normal file
19
apps/ui/web/src/api/tasks.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import type { PagedUiTask, ResetTaskResponse, TaskQuery } from "../types/backendTypes";
|
||||
import { apiGet, buildQuery } from "./client";
|
||||
|
||||
export function getTasks(query: TaskQuery) {
|
||||
const qs = buildQuery(query)
|
||||
return apiGet<PagedUiTask>(`/tasks?${qs}`)
|
||||
}
|
||||
|
||||
export function resetFailedTask(
|
||||
taskId: string,
|
||||
force: boolean,
|
||||
opts?: { onError?: (status: number, body: any) => void }
|
||||
) {
|
||||
if (force) {
|
||||
return apiGet<ResetTaskResponse>(`/tasks/${taskId}/reset/force`, opts)
|
||||
} else {
|
||||
return apiGet<ResetTaskResponse>(`/tasks/${taskId}/reset`, opts)
|
||||
}
|
||||
}
|
||||
@ -1,69 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import Drawer from '@mui/material/Drawer';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||
import ListItemText from '@mui/material/ListItemText';
|
||||
import InboxIcon from '@mui/icons-material/Inbox';
|
||||
import LibraryBooksIcon from '@mui/icons-material/LibraryBooks';
|
||||
|
||||
const drawerWidth = 240;
|
||||
|
||||
const SidebarWrapper = styled('div')({
|
||||
display: 'flex',
|
||||
});
|
||||
|
||||
const DrawerWrapper = styled(Drawer)({
|
||||
width: drawerWidth,
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
const DrawerPaperWrapper = styled('div')({
|
||||
width: drawerWidth,
|
||||
});
|
||||
|
||||
const ContentWrapper = styled('div')({
|
||||
flexGrow: 1,
|
||||
padding: '16px',
|
||||
});
|
||||
|
||||
export default function Sidebar() {
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
const handleToggle = () => {
|
||||
setOpen(!open);
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarWrapper>
|
||||
<DrawerWrapper
|
||||
variant="persistent"
|
||||
anchor="left"
|
||||
open={open}
|
||||
PaperProps={{
|
||||
sx: { width: drawerWidth },
|
||||
}}
|
||||
>
|
||||
<DrawerPaperWrapper />
|
||||
<List>
|
||||
<ListItem button>
|
||||
<ListItemIcon>
|
||||
<InboxIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Incoming" />
|
||||
</ListItem>
|
||||
<ListItem button>
|
||||
<ListItemIcon>
|
||||
<LibraryBooksIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Library" />
|
||||
</ListItem>
|
||||
</List>
|
||||
</DrawerWrapper>
|
||||
<ContentWrapper>
|
||||
{/* Main content */}
|
||||
</ContentWrapper>
|
||||
</SidebarWrapper>
|
||||
);
|
||||
}
|
||||
@ -1,64 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { RootState } from "../store";
|
||||
import { setContextMenuVisible } from "../store/context-menu-slice";
|
||||
import { Box, Typography, useTheme } from "@mui/material";
|
||||
|
||||
export interface ContextMenuItem {
|
||||
actionIndex: number,
|
||||
icon: JSX.Element | null,
|
||||
text: string
|
||||
}
|
||||
|
||||
type NullableContextMenuActionEvent<T> = ContextMenuActionEvent<T> | null;
|
||||
export interface ContextMenuActionEvent<T> {
|
||||
selected: (actionIndex: number | null, value: T | null) => void;
|
||||
}
|
||||
|
||||
export default function ContextMenu<T>({ actionItems, row = null, onContextMenuItemClicked} : { actionItems: ContextMenuItem[], row?: T | null, onContextMenuItemClicked?: ContextMenuActionEvent<T>}) {
|
||||
const muiTheme = useTheme();
|
||||
const state = useSelector((state: RootState) => state.contextMenu)
|
||||
const dispatch = useDispatch();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
setVisible(state.visible)
|
||||
const position = state.position
|
||||
if (position) {
|
||||
setPosition({top: position.y, left: position.x})
|
||||
}
|
||||
}, [state])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = () => dispatch(setContextMenuVisible(false));
|
||||
window.addEventListener("click", handleClick);
|
||||
return () => {
|
||||
window.removeEventListener("click", handleClick);
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{visible && (
|
||||
<Box className="contextmenu" sx={{
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
backgroundColor: muiTheme.palette.action.selected,
|
||||
}}>
|
||||
{ actionItems.map((item, index) => (
|
||||
<Box className="contextMenuItem" display="flex" key={index} onClick={() => {
|
||||
onContextMenuItemClicked?.selected(item.actionIndex, row)
|
||||
}} >
|
||||
{item.icon}
|
||||
<Typography>{item.text}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
{actionItems.length === 0 && (
|
||||
<Typography>Nothing to do..</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -1,35 +0,0 @@
|
||||
import { Typography } from "@mui/material";
|
||||
|
||||
|
||||
export function toUnixTimestamp({ timestamp }: { timestamp?: number }) {
|
||||
if (!timestamp) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(timestamp);
|
||||
const day = date.getDate();
|
||||
const month = date.toLocaleString('default', { month: 'short' });
|
||||
const year = date.getFullYear();
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
return `${day}.${month}.${year} ${hours}.${minutes}`;
|
||||
}
|
||||
|
||||
export function UnixTimestamp({ timestamp }: { timestamp?: number }) {
|
||||
if (!timestamp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = new Date(timestamp);
|
||||
const day = date.getDate();
|
||||
const month = date.toLocaleString('default', { month: 'short' });
|
||||
const year = date.getFullYear();
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
return (
|
||||
<>
|
||||
<Typography variant='body1'>
|
||||
{day}.{month}.{year} {hours}.{minutes}
|
||||
</Typography>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -1,43 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import LinearProgress, { LinearProgressProps } from '@mui/material/LinearProgress';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
export default function ProgressbarWithLabel({indeterminateText, progress}: {indeterminateText?: string, progress?: number | undefined | null}) {
|
||||
const variant = (progress) ? "determinate" : "indeterminate"
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Box sx={{ width: '100%', mr: 1 }}>
|
||||
<LinearProgress variant={variant} value={progress ?? 0} />
|
||||
</Box>
|
||||
<Box sx={{ minWidth: 35 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: 'text.secondary' }}>
|
||||
{progress ? (`${Math.round(progress)}%`) :
|
||||
indeterminateText}
|
||||
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/*export default function LinearWithValueLabel() {
|
||||
const [progress, setProgress] = React.useState(10);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setProgress((prevProgress) => (prevProgress >= 100 ? 10 : prevProgress + 10));
|
||||
}, 800);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<ProgressbarWithLabel value={progress} />
|
||||
</Box>
|
||||
);
|
||||
}*/
|
||||
@ -1,9 +0,0 @@
|
||||
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<>
|
||||
<div></div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -1,166 +0,0 @@
|
||||
import { Box, TableContainer, Table, TableHead, TableRow, TableCell, Typography, TableBody, useTheme } from "@mui/material";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { TablePropetyConfig, TableCellCustomizer, TableRowActionEvents, SortByAccessor, TableRowItem, DefaultTableContainer, ITableRow, SortBy, TableSorter } from "./table";
|
||||
import IconArrowUp from '@mui/icons-material/ArrowUpward';
|
||||
import IconArrowDown from '@mui/icons-material/ArrowDownward';
|
||||
|
||||
|
||||
export type ExpandableRender<T> = (item: T) => JSX.Element | null;
|
||||
|
||||
|
||||
export default function ExpandableTable<R extends ITableRow<U>, U>({ items, columns, cellCustomizer: customizer, expandableRender, onRowClickedEvent, defaultSort, sorter }: { items: Array<R>, columns: Array<TablePropetyConfig>, cellCustomizer?: TableCellCustomizer<R>, expandableRender: ExpandableRender<R>, onRowClickedEvent?: TableRowActionEvents<R>, defaultSort?: SortByAccessor, sorter?: TableSorter<R> }) {
|
||||
const muiTheme = useTheme();
|
||||
|
||||
const [order, setOrder] = useState<'asc' | 'desc'>(defaultSort?.order ?? 'asc');
|
||||
const [orderBy, setOrderBy] = useState<string>(defaultSort?.accessor ?? columns[0].accessor ?? '');
|
||||
const [expandedRowIds, setExpandedRowIds] = useState<Set<string>>(new Set());
|
||||
const [selectedRow, setSelectedRow] = useState<R | null>(null);
|
||||
const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
|
||||
|
||||
const tableRowSingleClicked = (row: R | null) => {
|
||||
console.log("tableRowSingleClicked", row)
|
||||
if (row != null && 'rowId' in row) {
|
||||
setExpandedRowIds(prev => {
|
||||
const newExpandedRows = new Set(prev);
|
||||
console.log("newExpandedRows", newExpandedRows)
|
||||
if (newExpandedRows.has(row.rowId)) {
|
||||
newExpandedRows.delete(row.rowId);
|
||||
} else {
|
||||
newExpandedRows.add(row.rowId);
|
||||
}
|
||||
return newExpandedRows;
|
||||
})
|
||||
}
|
||||
|
||||
if (row === selectedRow) {
|
||||
setSelectedRow(null);
|
||||
setSelectedRowId(null);
|
||||
} else {
|
||||
setSelectedRow(row);
|
||||
setSelectedRowId(row?.rowId ?? null);
|
||||
if (row && onRowClickedEvent) {
|
||||
onRowClickedEvent.click(row);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
const tableRowDoubleClicked = (row: R | null) => {
|
||||
setSelectedRow(row);
|
||||
if (row && onRowClickedEvent) {
|
||||
onRowClickedEvent.doubleClick(row);
|
||||
}
|
||||
}
|
||||
|
||||
const tableRowContextMenu = (e: React.MouseEvent<HTMLTableRowElement, MouseEvent> , row: R | null) => {
|
||||
if (row && onRowClickedEvent && onRowClickedEvent.contextMenu) {
|
||||
e.preventDefault()
|
||||
onRowClickedEvent.contextMenu(row, e.pageX, e.pageY)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const compareValues = (a: any, b: any, orderBy: string) => {
|
||||
console.log("compareValues", a, b, orderBy)
|
||||
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 = useMemo(() => {
|
||||
return [...items].sort((a, b) => {
|
||||
if (sorter) {
|
||||
return sorter(a, b, order, orderBy);
|
||||
} else {
|
||||
if (order === 'asc') {
|
||||
return compareValues(a, b, orderBy);
|
||||
} else {
|
||||
return compareValues(b, a, orderBy);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}, [items, order, orderBy]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRowId) {
|
||||
const matchingRow = items.find((item) => item.rowId === selectedRowId);
|
||||
if (matchingRow) {
|
||||
setSelectedRow(matchingRow);
|
||||
} else {
|
||||
setSelectedRow(null); // Hvis raden ikke finnes lenger, fjern valg
|
||||
setSelectedRowId(null);
|
||||
}
|
||||
}
|
||||
}, [items, selectedRowId]);
|
||||
|
||||
|
||||
const handleSort = (property: string) => {
|
||||
const isAsc = orderBy === property && order === 'asc';
|
||||
setOrder(isAsc ? 'desc' : 'asc');
|
||||
setOrderBy(property);
|
||||
console.log("handleSort", property, isAsc ? 'desc' : 'asc')
|
||||
};
|
||||
|
||||
return (
|
||||
<DefaultTableContainer>
|
||||
<Table>
|
||||
<TableHead sx={{
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
backgroundColor: muiTheme.palette.background.paper,
|
||||
}}>
|
||||
<TableRow key={`orderRow-${Math.random()}`}>
|
||||
{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>
|
||||
<TableBody sx={{
|
||||
overflowY: "scroll"
|
||||
}}>
|
||||
{sortedData?.map((row: R, rowIndex: number) => [
|
||||
<TableRow key={row.rowId}
|
||||
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>,
|
||||
(expandedRowIds.has(row.rowId)) ?
|
||||
(<TableRow key={row.rowId + "-expanded"}>
|
||||
<TableCell colSpan={columns.length}>
|
||||
{
|
||||
expandableRender(row)
|
||||
}
|
||||
</TableCell>
|
||||
</TableRow>): null
|
||||
|
||||
])}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DefaultTableContainer>
|
||||
)
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
|
||||
|
||||
export default function ExpandableTable() {
|
||||
return <></>
|
||||
} // ExpandableTable
|
||||
// ExpandableTable
|
||||
@ -1,33 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { ExpandableRender } from "./expandableTable";
|
||||
import { DefaultTableContainer, SortByAccessor, TableCellCustomizer, TablePropetyConfig, TableRowActionEvents, TableRowGroupedItem } from "./table";
|
||||
import { useTheme } from "@mui/material";
|
||||
|
||||
export interface GroupedTableProps<R extends TableRowGroupedItem<U>, U> {
|
||||
items: Array<R>;
|
||||
columns: Array<TablePropetyConfig>;
|
||||
cellCustomizer?: TableCellCustomizer<R>;
|
||||
onRowClickedEvent?: TableRowActionEvents<R>;
|
||||
defaultSort?: SortByAccessor;
|
||||
|
||||
}
|
||||
|
||||
|
||||
export default function GroupedTable<R extends TableRowGroupedItem<U>, U>({ items, columns, cellCustomizer: customizer, onRowClickedEvent }: GroupedTableProps<R, U>) {
|
||||
const muiTheme = useTheme();
|
||||
|
||||
const [order, setOrder] = useState<'asc' | 'desc'>('asc');
|
||||
const [orderBy, setOrderBy] = useState<string>('');
|
||||
const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
|
||||
|
||||
const handleSort = (property: string) => {
|
||||
const isAsc = orderBy === property && order === 'asc';
|
||||
setOrder(isAsc ? 'desc' : 'asc');
|
||||
setOrderBy(property);
|
||||
};
|
||||
|
||||
return (
|
||||
<></>
|
||||
);
|
||||
}
|
||||
@ -1,156 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -1,173 +0,0 @@
|
||||
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, TableRowGroupedItem, DefaultTableContainer } from "./table";
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
|
||||
|
||||
export default function SortableGroupedTable<T extends TableRowGroupedItem<T>>({ items, columns, customizer, onRowClickedEvent }: { items: Array<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<RowTableItem<T>> = items.map((item: RowTableItem<T>) => {
|
||||
if (items.length === 0 || !item.items) {
|
||||
console.log(item);
|
||||
return item; // Return the item as is if there are no items to sort
|
||||
}
|
||||
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);
|
||||
}
|
||||
})
|
||||
}
|
||||
});*/
|
||||
|
||||
const sortedData: Array<TableRowGroupedItem<T>> = items.map((item: TableRowGroupedItem<T>) => {
|
||||
return {
|
||||
...item,
|
||||
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 (
|
||||
<DefaultTableContainer>
|
||||
<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>
|
||||
</DefaultTableContainer>
|
||||
)
|
||||
}
|
||||
@ -1,126 +0,0 @@
|
||||
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";
|
||||
|
||||
|
||||
export default function SimpleTable<T>({ items, columns, customizer, onRowClickedEvent }: { items: Array<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 = 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>
|
||||
<TableBody sx={{
|
||||
overflowY: "scroll"
|
||||
}}>
|
||||
{sortedData?.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>
|
||||
)
|
||||
}
|
||||
@ -1,117 +0,0 @@
|
||||
import { Box, TableContainer } from "@mui/material";
|
||||
|
||||
export interface ITableRow<T> {
|
||||
rowId: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface TableRowItem<T> extends ITableRow<T> {
|
||||
rowId: string;
|
||||
title: string;
|
||||
item: T;
|
||||
}
|
||||
|
||||
export interface TableRowGroupedItem<T> extends ITableRow<T> {
|
||||
rowId: string;
|
||||
title: string;
|
||||
items: Array<T>
|
||||
}
|
||||
|
||||
|
||||
export interface TablePropetyConfig {
|
||||
label: string
|
||||
accessor: string
|
||||
}
|
||||
|
||||
export interface TableCellCustomizer<T> {
|
||||
(accessor: string, data: T): JSX.Element | null
|
||||
}
|
||||
|
||||
type NullableTableRowActionEvents<T> = TableRowActionEvents<T> | null;
|
||||
export interface TableRowActionEvents<T> {
|
||||
click: (row: T) => void;
|
||||
doubleClick: (row: T) => void;
|
||||
contextMenu?: (row: T, x: number, y: number) => void;
|
||||
}
|
||||
|
||||
export type SortBy = 'asc' | 'desc';
|
||||
|
||||
export interface SortByAccessor {
|
||||
accessor: string
|
||||
order: SortBy
|
||||
}
|
||||
|
||||
export type TableSorter<T> = (a: T, b: T, orderBy: SortBy, accessor: string) => number;
|
||||
|
||||
|
||||
export type SortableGroupedTableProps<R extends TableRowGroupedItem<U>, U> = {
|
||||
items: Array<R>;
|
||||
columns: Array<TablePropetyConfig>;
|
||||
cellCustomizer?: TableCellCustomizer<R>;
|
||||
onRowClickedEvent?: NullableTableRowActionEvents<R>;
|
||||
defaultSort?: SortByAccessor;
|
||||
}
|
||||
|
||||
|
||||
export function DefaultTableContainer({children}: {children?: JSX.Element}) {
|
||||
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
|
||||
}}>
|
||||
{children}
|
||||
</TableContainer>
|
||||
</Box >
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function KeybasedComparator(a: any, b: any, key: string) {
|
||||
if (typeof a[key] === 'string') {
|
||||
return a[key].localeCompare(b[key]);
|
||||
} else if (typeof a[key] === 'number') {
|
||||
return a[key] - b[key];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function SortGroupedTableItemsOnRoot<R extends TableRowItem<U>, U>(
|
||||
sortBy: SortByAccessor,
|
||||
items: Array<R>,
|
||||
) {
|
||||
return items.slice().sort((a, b) => {
|
||||
if (sortBy.order === 'asc') {
|
||||
return KeybasedComparator(a, b, sortBy.accessor);
|
||||
}
|
||||
return KeybasedComparator(b, a, sortBy.accessor);
|
||||
});
|
||||
}
|
||||
|
||||
export function SortGroupedTableItemsOnChilds<R extends TableRowGroupedItem<U>, U>(
|
||||
sortBy: SortByAccessor,
|
||||
items: Array<R>
|
||||
): Array<R> {
|
||||
return items.map(item => ({
|
||||
...item,
|
||||
items: item.items?.slice().sort((a, b) => {
|
||||
if (sortBy.order === "asc") {
|
||||
return KeybasedComparator(a, b, sortBy.accessor);
|
||||
}
|
||||
return KeybasedComparator(b, a, sortBy.accessor);
|
||||
})
|
||||
})).sort((a, b) => {
|
||||
if (sortBy.order === "asc") {
|
||||
return KeybasedComparator(a, b, sortBy.accessor);
|
||||
}
|
||||
return KeybasedComparator(b, a, sortBy.accessor);
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
export interface CoordinatorOperationRequest {
|
||||
destination: string;
|
||||
file: string;
|
||||
source: string;
|
||||
mode: "FLOW" | "MANUAL";
|
||||
}
|
||||
@ -1,52 +0,0 @@
|
||||
import React from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
import ReactDOMServer from "react-dom/server";
|
||||
|
||||
export const useCenteredTree = (defaultTranslate = { x: 0, y: 0 }) => {
|
||||
const [translate, setTranslate] = useState<{ x: number; y: number }>(defaultTranslate);
|
||||
const [dimensions, setDimensions] = useState<{ width: number; height: number }>({ width: 0, height: 0 });
|
||||
|
||||
const containerRef = useCallback((containerElem: HTMLDivElement | null) => {
|
||||
if (containerElem !== null) {
|
||||
const { width, height } = containerElem.getBoundingClientRect();
|
||||
setDimensions({ width, height });
|
||||
setTranslate({ x: width / 3, y: height / 2 });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return [dimensions, translate, containerRef] as const; // Merk: `as const` sikrer faste typer
|
||||
};
|
||||
|
||||
|
||||
export const extractSvgContent = (icon: JSX.Element | null): JSX.Element[] => {
|
||||
if (icon === null) {
|
||||
return [];
|
||||
}
|
||||
const svgMarkup = ReactDOMServer.renderToString(icon);
|
||||
const parsedSvg = new DOMParser().parseFromString(svgMarkup, 'image/svg+xml');
|
||||
|
||||
// Hent viewBox-attributtet fra SVG
|
||||
const svgElement = parsedSvg.documentElement;
|
||||
const viewBox = svgElement.getAttribute('viewBox');
|
||||
|
||||
if (!viewBox) {
|
||||
throw new Error('SVG mangler viewBox-attributt');
|
||||
}
|
||||
|
||||
// Ekstraher minX, minY, width, height fra viewBox
|
||||
const [minX, minY, width, height] = viewBox.split(' ').map(Number);
|
||||
|
||||
// Beregn offset for senteret og sett det som negativt transform
|
||||
const offsetX = -(minX + width / 2);
|
||||
const offsetY = -(minY + height / 2);
|
||||
|
||||
return Array.from(parsedSvg.documentElement.children).map((child, index) => {
|
||||
const props = {
|
||||
key: index,
|
||||
transform: `translate(${offsetX}, ${offsetY})`, // Juster transform med negativt offset
|
||||
...Array.from(child.attributes).reduce((acc, { name, value }) => ({ ...acc, [name]: value }), {}),
|
||||
};
|
||||
|
||||
return React.createElement(child.tagName, props, null);
|
||||
});
|
||||
};
|
||||
@ -1,6 +0,0 @@
|
||||
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
|
||||
import type { RootState, AppDispatch } from './store';
|
||||
|
||||
// Use throughout your app instead of plain `useDispatch` and `useSelector`
|
||||
export const useAppDispatch = () => useDispatch<AppDispatch>();
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||
@ -1,191 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useStompClient } from 'react-stomp-hooks';
|
||||
import { RootState } from "../store";
|
||||
import { useWsSubscription } from "../ws/subscriptions";
|
||||
import { EventsObjectListResponse } from "../../types";
|
||||
import IconRefresh from '@mui/icons-material/Refresh'
|
||||
import { Box, Button, Typography, useTheme } from "@mui/material";
|
||||
import { Tree } from "react-d3-tree";
|
||||
import { EventChain, EventGroup, EventGroups, set } from "../store/chained-events-slice";
|
||||
import { toUnixTimestamp, UnixTimestamp } from "../features/UxTc";
|
||||
import { TableCellCustomizer, TablePropetyConfig, TableRowGroupedItem } from "../features/table/table";
|
||||
import ExpandableTable from "../features/table/expandableTable";
|
||||
import { CustomNodeElementProps } from 'react-d3-tree';
|
||||
|
||||
export type ExpandableItemRow = TableRowGroupedItem<EventChain> & EventGroup
|
||||
|
||||
|
||||
interface RawNodeDatum {
|
||||
name: string;
|
||||
attributes?: Record<string, string | number | boolean>;
|
||||
children?: RawNodeDatum[];
|
||||
fill?: string;
|
||||
}
|
||||
|
||||
// Transformasjonsfunksjon for EventChain
|
||||
const transformEventChain = (eventChain: EventChain): RawNodeDatum => ({
|
||||
name: eventChain.eventName,
|
||||
attributes: {
|
||||
//eventId: eventChain.eventId,
|
||||
created: toUnixTimestamp({ timestamp: eventChain.created }) ?? "",
|
||||
success: eventChain.success,
|
||||
skipped: eventChain.skipped,
|
||||
failure: eventChain.failure,
|
||||
childrens: eventChain.events.length
|
||||
},
|
||||
fill: "white",
|
||||
children: eventChain.events.map(transformEventChain),
|
||||
});
|
||||
|
||||
// Transformasjonsfunksjon for EventGroups
|
||||
const transformEventGroups = (group: ExpandableItemRow): RawNodeDatum[] => {
|
||||
return group.items.map(transformEventChain);
|
||||
}
|
||||
|
||||
export default function EventsChainPage() {
|
||||
const muiTheme = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
const client = useStompClient();
|
||||
const cursor = useSelector((state: RootState) => state.chained)
|
||||
const [useReferenceId, setUseReferenceId] = useState<string | null>(null);
|
||||
const [tableItems, setTableItems] = useState<Array<ExpandableItemRow>>([]);
|
||||
|
||||
useWsSubscription<Array<EventGroup>>("/topic/chained/all", (response) => {
|
||||
dispatch(set(response))
|
||||
console.log(response)
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const items = cursor.groups.map((group: EventGroup) => {
|
||||
const created = group.events[0]?.created ?? 0;
|
||||
return {
|
||||
rowId: group.referenceId,
|
||||
title: group.fileName ?? group.referenceId,
|
||||
items: group.events,
|
||||
created: created,
|
||||
referenceId: group.referenceId,
|
||||
} as ExpandableItemRow
|
||||
});
|
||||
setTableItems(items);
|
||||
console.log("tableItems", items)
|
||||
}, [cursor]);
|
||||
|
||||
useEffect(() => {
|
||||
client?.publish({
|
||||
destination: "/app/chained/all"
|
||||
});
|
||||
}, [client, dispatch]);
|
||||
|
||||
const onRefresh = () => {
|
||||
client?.publish({
|
||||
"destination": "/app/chained/all",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
const createTableCell: TableCellCustomizer<EventGroup> = (accessor, data) => {
|
||||
console.log(accessor, data);
|
||||
|
||||
switch (accessor) {
|
||||
case "created": {
|
||||
if (typeof data[accessor] === "number") {
|
||||
const timestampObject = { timestamp: data[accessor] as number }; // Opprett et objekt med riktig struktur
|
||||
return UnixTimestamp(timestampObject);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
const columns: Array<TablePropetyConfig> = [
|
||||
{ label: "ReferenceId", accessor: "referenceId" },
|
||||
{ label: "File", accessor: "title" },
|
||||
{ label: "Created", accessor: "created" },
|
||||
];
|
||||
|
||||
|
||||
function renderCustomNodeElement(nodeData: CustomNodeElementProps): JSX.Element {
|
||||
const attr = nodeData.nodeDatum.attributes;
|
||||
const nodeColor = attr?.success ? "green" : attr?.failure ? "red" : "yellow";
|
||||
return (<>
|
||||
<g>
|
||||
<circle r="15" fill={nodeColor} onClick={nodeData.toggleNode} />
|
||||
<text fill="white" stroke="white" strokeWidth="0" x="20">
|
||||
{nodeData.nodeDatum.name}
|
||||
</text>
|
||||
{attr?.created && (
|
||||
<text fill="lightgray" x="20" dy="20" strokeWidth="0">
|
||||
Created: {attr?.created}
|
||||
</text>
|
||||
)}
|
||||
{attr?.success && (
|
||||
<text fill="lightgray" x="20" dy="40" strokeWidth="0">
|
||||
Success
|
||||
</text>
|
||||
)}
|
||||
{attr?.failure && (
|
||||
<text fill="lightgray" x="20" dy="40" strokeWidth="0">
|
||||
Failure
|
||||
</text>
|
||||
)}
|
||||
{attr?.skipped && (
|
||||
<text fill="lightgray" x="20" dy="40" strokeWidth="0">
|
||||
Skipped
|
||||
</text>
|
||||
)}
|
||||
{attr?.childrens && (
|
||||
<text fill="lightgray" x="20" dy="60" strokeWidth="0">
|
||||
{attr?.childrens} derived {Number(attr?.childrens) > 1 ? "events" : "event"}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
</>);
|
||||
}
|
||||
|
||||
function renderExpandableItem(item: ExpandableItemRow): JSX.Element | null {
|
||||
console.log(item);
|
||||
const data = transformEventGroups(item);
|
||||
return (
|
||||
<>
|
||||
{(data) ? (
|
||||
<div id="treeWrapper" style={{ width: '100%', height: '60vh', }}>
|
||||
<Tree data={data} orientation="vertical" separation={{
|
||||
nonSiblings: 3,
|
||||
siblings: 2
|
||||
}}
|
||||
renderCustomNodeElement={renderCustomNodeElement}
|
||||
/>
|
||||
</div>
|
||||
) : <Typography>Tree data not available</Typography>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
startIcon={<IconRefresh />}
|
||||
onClick={onRefresh} sx={{
|
||||
borderRadius: 5,
|
||||
textTransform: 'none'
|
||||
}}>Refresh
|
||||
</Button>
|
||||
<Box display="block">
|
||||
<Box sx={{
|
||||
display: "block",
|
||||
height: "calc(100% - 120px)",
|
||||
overflow: "hidden",
|
||||
position: "absolute",
|
||||
width: "100%"
|
||||
}}>
|
||||
<ExpandableTable items={tableItems ?? []} columns={columns} cellCustomizer={createTableCell} expandableRender={renderExpandableItem} />
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
.thicc-link {
|
||||
stroke-width: 3;
|
||||
stroke: black!important;
|
||||
}
|
||||
@ -1,303 +0,0 @@
|
||||
import Tree, { CustomNodeElementProps } from "react-d3-tree";
|
||||
import { useWsSubscription } from "../ws/subscriptions"
|
||||
import NotStartedIcon from '@mui/icons-material/NotStarted';
|
||||
import ReactDOMServer from 'react-dom/server';
|
||||
import React, { ReactNode, useCallback, useEffect, useState } from "react";
|
||||
import { extractSvgContent, useCenteredTree } from "../features/util";
|
||||
import './EventsPage.css';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import KeyboardDoubleArrowRightIcon from '@mui/icons-material/KeyboardDoubleArrowRight';
|
||||
import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty';
|
||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
||||
import SubtitlesIcon from '@mui/icons-material/Subtitles';
|
||||
import MovieIcon from '@mui/icons-material/Movie';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
|
||||
import AutoAwesomeMotionIcon from '@mui/icons-material/AutoAwesomeMotion';
|
||||
import { client } from "stompjs";
|
||||
import { useStompClient } from "react-stomp-hooks";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { ContentEventState, ContentEventStateItems, ProcesserEventInfo, Status, update, updateEncodeProgress, WorkStatus } from "../store/work-slice";
|
||||
import ExpandableTable from "../features/table/expandableTable";
|
||||
import { RootState } from "../store";
|
||||
import { KeybasedComparator, SortBy, TableCellCustomizer, TablePropetyConfig, TableRowItem } from "../features/table/table";
|
||||
import SimpleTable from "../features/table/sortableTable";
|
||||
import { UnixTimestamp } from "../features/UxTc";
|
||||
import ProgressbarWithLabel from "../features/components/ProgressbarWithLabel";
|
||||
import { LinearProgress, Typography } from "@mui/material";
|
||||
import { stat } from "fs";
|
||||
|
||||
interface RawNodeDatum {
|
||||
name: string;
|
||||
attributes?: Record<string, string | number | boolean>;
|
||||
children?: RawNodeDatum[];
|
||||
fill?: string;
|
||||
}
|
||||
|
||||
interface EventNodeData {
|
||||
statusIcon: JSX.Element | null
|
||||
statusColor: string
|
||||
}
|
||||
|
||||
function getEventNodeStatus(status: Status): EventNodeData {
|
||||
const toData = (status: Status): EventNodeData => {
|
||||
switch (status) {
|
||||
case Status.NeedsApproval: {
|
||||
return {
|
||||
statusIcon: <NotStartedIcon />,
|
||||
statusColor: "crimson"
|
||||
} as EventNodeData
|
||||
}
|
||||
case Status.Completed: {
|
||||
return {
|
||||
statusIcon: <CheckIcon />,
|
||||
statusColor: "forestgreen"
|
||||
}
|
||||
}
|
||||
case Status.Skipped: {
|
||||
return {
|
||||
statusIcon: <KeyboardDoubleArrowRightIcon />,
|
||||
statusColor: "#313131"
|
||||
}
|
||||
}
|
||||
case Status.Awaiting: {
|
||||
return {
|
||||
statusIcon: <MoreHorizIcon />,
|
||||
statusColor: "#313131"
|
||||
}
|
||||
}
|
||||
case Status.Pending: {
|
||||
return {
|
||||
statusIcon: <HourglassEmptyIcon />,
|
||||
statusColor: "#ffa000"
|
||||
}
|
||||
}
|
||||
case Status.InProgress: {
|
||||
return {
|
||||
statusIcon: <HourglassEmptyIcon />,
|
||||
statusColor: "dodgerblue"
|
||||
}
|
||||
}
|
||||
case Status.Failed: {
|
||||
return {
|
||||
statusIcon: <ClearIcon />,
|
||||
statusColor: "crimson"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return toData(status);
|
||||
}
|
||||
|
||||
|
||||
function renderCustomNodeElement(nodeData: CustomNodeElementProps): JSX.Element {
|
||||
const attr = nodeData.nodeDatum.attributes;
|
||||
let workIcon: JSX.Element | null = null
|
||||
switch (attr?.type) {
|
||||
case "encode": {
|
||||
workIcon = <MovieIcon />
|
||||
break;
|
||||
}
|
||||
case "extract": {
|
||||
workIcon = <SubtitlesIcon />
|
||||
break;
|
||||
}
|
||||
case "convert": {
|
||||
workIcon = <AutoAwesomeMotionIcon />
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
const status = getEventNodeStatus((attr?.status as Status) ?? Status.Awaiting)
|
||||
|
||||
return (<>
|
||||
<g>
|
||||
<circle r="20" strokeWidth={3} fill={status.statusColor} />
|
||||
<g transform="scale(1.5)" stroke="none" fill="white">
|
||||
{extractSvgContent(status.statusIcon)}
|
||||
</g>
|
||||
<g transform="translate(0, 45) scale(1)" stroke="none" fill="white">
|
||||
{extractSvgContent(workIcon)}
|
||||
</g>
|
||||
</g>
|
||||
|
||||
|
||||
</>);
|
||||
}
|
||||
|
||||
const taskOperationStatusToStatus = (status: WorkStatus): Status | undefined => {
|
||||
switch (status) {
|
||||
case WorkStatus.Completed:
|
||||
return Status.Completed;
|
||||
case WorkStatus.Failed:
|
||||
return Status.Failed;
|
||||
case WorkStatus.Pending:
|
||||
return Status.Pending;
|
||||
case WorkStatus.Started:
|
||||
case WorkStatus.Working:
|
||||
return Status.InProgress;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const transformToSteps = (state: ContentEventState): RawNodeDatum => ({
|
||||
name: state.referenceId,
|
||||
attributes: {
|
||||
type: "encode",
|
||||
status: taskOperationStatusToStatus(state?.encodeWork?.status) ?? state.encode
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: state.referenceId,
|
||||
attributes: {
|
||||
type: "extract",
|
||||
status: state.extract
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: state.referenceId,
|
||||
attributes: {
|
||||
type: "convert",
|
||||
status: state.extract
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
]
|
||||
});
|
||||
|
||||
export type ExpandableItemRow = TableRowItem<ContentEventState>
|
||||
|
||||
export default function EventsPage() {
|
||||
const client = useStompClient();
|
||||
const dispatch = useDispatch();
|
||||
const events: ContentEventStateItems = useSelector((state: RootState) => state.work);
|
||||
const [tableItems, setTableItems] = useState<Array<ExpandableItemRow>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const items = events.items.map((event: ContentEventState) => {
|
||||
return {
|
||||
rowId: event.referenceId,
|
||||
title: event.referenceId,
|
||||
item: event
|
||||
} as ExpandableItemRow
|
||||
});
|
||||
setTableItems(items);
|
||||
}, [events]);
|
||||
|
||||
useWsSubscription<Array<ContentEventState>>("/topic/tasks/all", (response) => {
|
||||
console.log(response)
|
||||
dispatch(update(response))
|
||||
});
|
||||
|
||||
useWsSubscription<ProcesserEventInfo>("/topic/processer/encode/progress", (response) => {
|
||||
console.log(response)
|
||||
dispatch(updateEncodeProgress(response))
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
client?.publish({
|
||||
destination: "/app/tasks/all"
|
||||
})
|
||||
}, [client]);
|
||||
|
||||
const createCellTable: TableCellCustomizer<ExpandableItemRow> = (accessor, data) => {
|
||||
switch (accessor) {
|
||||
case "runners": {
|
||||
return (<>
|
||||
<div id="treeWrapper" style={{
|
||||
...linkThicc,
|
||||
width: '150px', height: '90px'
|
||||
}} ref={containerRef}>
|
||||
<Tree
|
||||
dimensions={dimensions}
|
||||
translate={{
|
||||
x: 24,
|
||||
y: 24
|
||||
}}
|
||||
data={transformToSteps(data.item)}
|
||||
orientation="horizontal"
|
||||
separation={{
|
||||
nonSiblings: 1,
|
||||
siblings: 1,
|
||||
}}
|
||||
nodeSize={{
|
||||
x: 50,
|
||||
y: 50
|
||||
}}
|
||||
draggable={false}
|
||||
zoomable={false}
|
||||
renderCustomNodeElement={renderCustomNodeElement}
|
||||
pathClassFunc={() => 'thicc-link'}
|
||||
|
||||
/>
|
||||
</div>
|
||||
</>)
|
||||
};
|
||||
case "created": {
|
||||
if (typeof data.item[accessor] === "number") {
|
||||
return UnixTimestamp({ timestamp: data.item[accessor] });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
const sorter = (a: ExpandableItemRow, b: ExpandableItemRow, orderBy: SortBy, accessor: string) => {
|
||||
if (orderBy === "asc") {
|
||||
return KeybasedComparator(a.item, b.item, "created")
|
||||
} else {
|
||||
return KeybasedComparator(b.item, a.item, "created")
|
||||
}
|
||||
}
|
||||
|
||||
function renderExpandableItem(item: ExpandableItemRow): JSX.Element | null {
|
||||
const data = item.item;
|
||||
const progress = data.encodeWork?.progress?.progress ?? undefined;
|
||||
const showProgressbar = [WorkStatus.Pending, WorkStatus.Started, WorkStatus.Working, WorkStatus.Completed].includes(data?.encodeWork?.status)
|
||||
|
||||
const processer = data.encodeWork // events.encodeWork[item.referenceId];
|
||||
const showIndeterminate = processer?.status in [WorkStatus.Pending, WorkStatus.Started] || processer?.progress?.progress <= 0
|
||||
console.log({
|
||||
type: "info",
|
||||
processer: processer,
|
||||
showIndeterminate: showIndeterminate,
|
||||
showProgressbar: showProgressbar,
|
||||
isWorking: data.encodeWork?.status == WorkStatus.Working
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<Typography>{data.encodeWork?.progress?.timeLeft}</Typography>
|
||||
{(showProgressbar) ?
|
||||
<ProgressbarWithLabel indeterminateText={"Waiting"} progress={progress} /> : null
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const columns: Array<TablePropetyConfig> = [
|
||||
{ label: "Title", accessor: "title" },
|
||||
{ label: "Started", accessor: "created" },
|
||||
{ label: "", accessor: "runners" },
|
||||
];
|
||||
|
||||
|
||||
const [dimensions, translate, containerRef] = useCenteredTree();
|
||||
|
||||
const linkThicc = {
|
||||
strokeWith: 5
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ExpandableTable items={tableItems} columns={columns} cellCustomizer={createCellTable} expandableRender={renderExpandableItem} defaultSort={{
|
||||
order: 'desc',
|
||||
accessor: "created"
|
||||
}} sorter={sorter} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -1,377 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { UnixTimestamp } from '../features/UxTc';
|
||||
import { Box, Button, IconButton, Typography, useTheme } from '@mui/material';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { RootState } from '../store';
|
||||
import { TableCellCustomizer, TablePropetyConfig, TableRowActionEvents } from '../features/table/table';
|
||||
import { useStompClient } from 'react-stomp-hooks';
|
||||
import { useWsSubscription } from '../ws/subscriptions';
|
||||
import { updateItems } from '../store/explorer-slice';
|
||||
import { setContextMenuPosition, setContextMenuVisible } from '../store/context-menu-slice';
|
||||
import FolderIcon from '@mui/icons-material/Folder';
|
||||
import IconForward from '@mui/icons-material/ArrowForwardIosRounded';
|
||||
import IconHome from '@mui/icons-material/Home';
|
||||
import { ExplorerItem, ExplorerCursor, ExplorerItemType } from '../../types';
|
||||
import ContextMenu, { ContextMenuActionEvent, ContextMenuItem } from '../features/ContextMenu';
|
||||
import { canConvert, canEncode, canExtract } from '../../fileUtil';
|
||||
import SimpleTable from '../features/table/sortableTable';
|
||||
import TagIcon from '@mui/icons-material/Tag';
|
||||
import { CoordinatorOperationRequest } from '../features/types';
|
||||
|
||||
|
||||
const createTableCell: TableCellCustomizer<ExplorerItem> = (accessor, data) => {
|
||||
switch (accessor) {
|
||||
case "created": {
|
||||
if (typeof data[accessor] === "number") {
|
||||
const timestampObject = { timestamp: data[accessor] as number }; // Opprett et objekt med riktig struktur
|
||||
return UnixTimestamp(timestampObject);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
case "extension": {
|
||||
if (data[accessor] === null) {
|
||||
return <FolderIcon sx={{ margin: 1 }} />
|
||||
} else {
|
||||
return <Typography>{data[accessor]}</Typography>
|
||||
}
|
||||
}
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
const columns: Array<TablePropetyConfig> = [
|
||||
{ label: "Name", accessor: "name" },
|
||||
{ label: "Format", accessor: "extension" },
|
||||
{ label: "Created", accessor: "created" },
|
||||
];
|
||||
|
||||
|
||||
|
||||
type Segment = {
|
||||
name: string;
|
||||
absolutePath: string;
|
||||
};
|
||||
|
||||
function isWindowsPath(path: string): boolean {
|
||||
return /^[A-Za-z]:\\/.test(path);
|
||||
}
|
||||
|
||||
function getPartFor(path: string, index: number): string | null {
|
||||
const separator = path.includes("/") ? "/" : "\\";
|
||||
|
||||
let parts: string[];
|
||||
if (isWindowsPath(path)) {
|
||||
parts = [path.slice(0, 3), ...path.slice(3).split(separator)];
|
||||
} else {
|
||||
if (path.length == 1 && index == 0) {
|
||||
return "/"
|
||||
}
|
||||
|
||||
if (path.startsWith(separator)) {
|
||||
parts = [separator, ...path.slice(1).split(separator)];
|
||||
} else {
|
||||
parts = path.split(separator);
|
||||
}
|
||||
}
|
||||
|
||||
if (index < parts.length) {
|
||||
// Unngå å legge til ekstra backslash for første element i Windows-path
|
||||
if (isWindowsPath(path) && index === 0) {
|
||||
return parts[0];
|
||||
}
|
||||
|
||||
|
||||
const returningPath = parts.slice(0, index + 1).join(separator).replace(/\/\//g, "/");
|
||||
return returningPath;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSegments(absolutePath: string): Array<Segment> {
|
||||
if (!absolutePath) return [];
|
||||
|
||||
const isWindows = isWindowsPath(absolutePath);
|
||||
const separator = isWindows ? "\\" : "/";
|
||||
|
||||
let parts: string[];
|
||||
if (isWindows) {
|
||||
parts = [absolutePath.slice(0, 3), ...absolutePath.slice(3).split(separator)];
|
||||
} else if (absolutePath.startsWith(separator)) {
|
||||
parts = [separator, ...absolutePath.slice(1).split(separator)];
|
||||
} else {
|
||||
parts = absolutePath.split(separator);
|
||||
}
|
||||
|
||||
const segments = parts.map((value, index) => {
|
||||
const name = isWindows && index === 0 ? value[0] : value;
|
||||
|
||||
return {
|
||||
name: name.replaceAll(":", ""),
|
||||
absolutePath: getPartFor(absolutePath, index)!
|
||||
};
|
||||
});
|
||||
|
||||
console.log({
|
||||
segments: segments,
|
||||
path: absolutePath
|
||||
});
|
||||
|
||||
return segments.filter((segment) => segment.name !== "");
|
||||
}
|
||||
|
||||
|
||||
|
||||
function getSegmentedNaviagatablePath(rootClick: () => void, navigateTo: (path: string | null) => void, path: string | null): JSX.Element {
|
||||
const segments = getSegments(path!)
|
||||
|
||||
const utElements = segments.map((segment: Segment, index: number) => {
|
||||
return (
|
||||
<Box key={index} sx={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center"
|
||||
}}>
|
||||
<Button sx={{
|
||||
borderRadius: 5,
|
||||
textTransform: 'none'
|
||||
}} onClick={() => navigateTo(segment.absolutePath!)}>
|
||||
<Typography>{segment.name}</Typography>
|
||||
</Button>
|
||||
{ segments.length > 1 && index < segments.length - 1 && <IconForward fontSize="small" />}
|
||||
</Box>
|
||||
)
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<Box display="flex">
|
||||
{isWindowsPath(path!) && (
|
||||
<Box sx={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center"
|
||||
}}>
|
||||
<IconButton sx={{
|
||||
borderRadius: 5,
|
||||
textTransform: 'none'
|
||||
}} onClick={() => rootClick()} >
|
||||
<TagIcon />
|
||||
</IconButton>
|
||||
<IconForward fontSize="small" />
|
||||
</Box>
|
||||
)}
|
||||
{utElements}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function getContextMenuFileActionMenuItems(row: ExplorerItem | null): ContextMenuItem[] {
|
||||
const ext = row?.extension;
|
||||
const items: Array<ContextMenuItem> = [];
|
||||
if (!ext) {return items;}
|
||||
if (canEncode(ext) && canExtract(ext)) {
|
||||
items.push({
|
||||
actionIndex: 0,
|
||||
icon: null,
|
||||
text: "All available"
|
||||
} as ContextMenuItem)
|
||||
}
|
||||
if (canEncode(ext)) {
|
||||
items.push({
|
||||
actionIndex: 1,
|
||||
icon: null,
|
||||
text: "Encode"
|
||||
} as ContextMenuItem)
|
||||
}
|
||||
|
||||
if (canExtract(ext)) {
|
||||
items.push({
|
||||
actionIndex: 2,
|
||||
icon: null,
|
||||
text: "Extract"
|
||||
} as ContextMenuItem)
|
||||
}
|
||||
|
||||
if (canConvert(ext)) {
|
||||
items.push({
|
||||
actionIndex: 3,
|
||||
icon: null,
|
||||
text: "Convert"
|
||||
} as ContextMenuItem)
|
||||
}
|
||||
console.log(items);
|
||||
return items;
|
||||
}
|
||||
|
||||
export default function ExplorePage() {
|
||||
const muiTheme = useTheme();
|
||||
const dispatch = useDispatch();
|
||||
const client = useStompClient();
|
||||
const cursor = useSelector((state: RootState) => state.explorer)
|
||||
const [selectedRow, setSelectedRow] = useState<ExplorerItem|null>(null);
|
||||
const [actionableItems, setActionableItems] = useState<Array<ContextMenuItem>>([]);
|
||||
|
||||
|
||||
const navigateTo = (path: string | null) => {
|
||||
console.log(path)
|
||||
if (path) {
|
||||
client?.publish({
|
||||
destination: "/app/explorer/navigate",
|
||||
body: path
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const onItemSelectedEvent: TableRowActionEvents<ExplorerItem> = {
|
||||
click: (row: ExplorerItem) => {
|
||||
setSelectedRow(row)
|
||||
},
|
||||
doubleClick: (row: ExplorerItem) => {
|
||||
console.log(row);
|
||||
if (row.type === "FOLDER") {
|
||||
navigateTo(row.path);
|
||||
}
|
||||
},
|
||||
contextMenu: (row: ExplorerItem, x: number, y: number) => {
|
||||
if (row.type === "FOLDER") {
|
||||
return;
|
||||
}
|
||||
dispatch(setContextMenuVisible(true))
|
||||
dispatch(setContextMenuPosition({x: x, y: y}))
|
||||
setActionableItems(getContextMenuFileActionMenuItems(row))
|
||||
}
|
||||
}
|
||||
|
||||
const onContextMenuItemClickedEvent: ContextMenuActionEvent<ExplorerItem> = {
|
||||
selected:(actionIndex: number | null, value: ExplorerItem | null) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
const payload = (() => {
|
||||
switch(actionIndex) {
|
||||
case 0: {
|
||||
return {
|
||||
destination: "request/all",
|
||||
file: value.path,
|
||||
source: `Web UI @ ${window.location.href}`,
|
||||
mode: "FLOW"
|
||||
} as CoordinatorOperationRequest
|
||||
}
|
||||
case 1: {
|
||||
return {
|
||||
destination: "request/encode",
|
||||
file: value.path,
|
||||
source: `Web UI @ ${window.location.href}`,
|
||||
mode: "FLOW"
|
||||
} as CoordinatorOperationRequest
|
||||
}
|
||||
case 2: {
|
||||
return {
|
||||
destination: "request/extract",
|
||||
file: value.path,
|
||||
source: `Web UI @ ${window.location.href}`,
|
||||
mode: "FLOW"
|
||||
} as CoordinatorOperationRequest
|
||||
}
|
||||
case 3: {
|
||||
return {
|
||||
destination: "request/convert",
|
||||
file: value.path,
|
||||
source: `Web UI @ ${window.location.href}`,
|
||||
mode: "FLOW"
|
||||
} as CoordinatorOperationRequest
|
||||
}
|
||||
default: {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
})();
|
||||
if (payload) {
|
||||
client?.publish({
|
||||
destination: "/app/"+payload.destination,
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onHomeClick = () => {
|
||||
client?.publish({
|
||||
destination: "/app/explorer/home"
|
||||
})
|
||||
}
|
||||
|
||||
const onRootClick = () => {
|
||||
client?.publish({
|
||||
destination: "/app/explorer/root"
|
||||
})
|
||||
}
|
||||
|
||||
useWsSubscription<ExplorerCursor>("/topic/explorer/go", (response) => {
|
||||
dispatch(updateItems(response))
|
||||
});
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
// Kjør din funksjon her når komponenten lastes inn for første gang
|
||||
// Sjekk om cursor er null
|
||||
if (cursor.path === null && client !== null) {
|
||||
console.log(cursor)
|
||||
// Kjør din funksjon her når cursor er null og client ikke er null
|
||||
client?.publish({
|
||||
destination: "/app/explorer/home"
|
||||
});
|
||||
|
||||
// Alternativt, du kan dispatche en Redux handling her
|
||||
// dispatch(fetchDataAction()); // Eksempel på å dispatche en handling
|
||||
}
|
||||
}, [cursor, client, dispatch]);
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box display="block">
|
||||
<Box sx={{
|
||||
height: 50,
|
||||
width: "100%",
|
||||
maxHeight: "100%",
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
backgroundColor: muiTheme.palette.background.paper
|
||||
}}>
|
||||
<Box sx={{
|
||||
display: "flex",
|
||||
}}>
|
||||
<Button onClick={onHomeClick} sx={{
|
||||
borderRadius: 5
|
||||
}}>
|
||||
<IconHome />
|
||||
</Button>
|
||||
<Box sx={{
|
||||
borderRadius: 5,
|
||||
backgroundColor: muiTheme.palette.divider
|
||||
}}>
|
||||
{getSegmentedNaviagatablePath(onRootClick, navigateTo, cursor?.path)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{
|
||||
display: "block",
|
||||
height: "calc(100% - 120px)",
|
||||
overflow: "hidden",
|
||||
position: "absolute",
|
||||
width: "100%"
|
||||
}}>
|
||||
<SimpleTable items={cursor?.items ?? []} columns={columns} customizer={createTableCell} onRowClickedEvent={onItemSelectedEvent} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<ContextMenu row={selectedRow} actionItems={actionableItems} onContextMenuItemClicked={onContextMenuItemClickedEvent} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,88 +0,0 @@
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { RootState } from "../store";
|
||||
import { useEffect } from "react";
|
||||
import { useStompClient } from "react-stomp-hooks";
|
||||
import { Box, Button, IconButton, Typography, useTheme } from "@mui/material";
|
||||
import IconRefresh from '@mui/icons-material/Refresh'
|
||||
import IconCompleted from '@mui/icons-material/Check'
|
||||
import IconWorking from '@mui/icons-material/Engineering';
|
||||
import { TablePropetyConfig } from "../features/table/table";
|
||||
import SimpleTable from "../features/table/sortableTable";
|
||||
|
||||
const columns: Array<TablePropetyConfig> = [
|
||||
{ label: "Title", accessor: "givenTitle" },
|
||||
{ label: "Type", accessor: "determinedType" },
|
||||
{ label: "Collection", accessor: "givenCollection" },
|
||||
{ label: "Encoded", accessor: "eventEncoded" }
|
||||
];
|
||||
|
||||
|
||||
|
||||
export default function LaunchPage() {
|
||||
const dispatch = useDispatch();
|
||||
const muiTheme = useTheme();
|
||||
const client = useStompClient();
|
||||
/*useEffect(() => {
|
||||
if (simpleList.items.filter((item) => item.encodingTimeLeft !== null).length > 0) {
|
||||
columns.push({
|
||||
label: "Completion",
|
||||
accessor: "encodingTimeLeft"
|
||||
})
|
||||
}
|
||||
}, [simpleList, dispatch])*/
|
||||
|
||||
const onRefresh = () => {
|
||||
client?.publish({
|
||||
"destination": "/app/items",
|
||||
"body": "Potato"
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Box display="block">
|
||||
<Box sx={{
|
||||
height: 50,
|
||||
width: "100%",
|
||||
maxHeight: "100%",
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
backgroundColor: muiTheme.palette.background.paper
|
||||
}}>
|
||||
<Box sx={{
|
||||
display: "flex",
|
||||
}}>
|
||||
<Button
|
||||
startIcon={ <IconRefresh /> }
|
||||
onClick={onRefresh} sx={{
|
||||
borderRadius: 5,
|
||||
textTransform: 'none'
|
||||
}}>Refresh</Button >
|
||||
<Button
|
||||
startIcon={ <IconCompleted /> }
|
||||
onClick={onRefresh} sx={{
|
||||
borderRadius: 5,
|
||||
textTransform: 'none'
|
||||
}}>Completed</Button >
|
||||
<Button
|
||||
startIcon={ <IconWorking /> }
|
||||
onClick={onRefresh} sx={{
|
||||
borderRadius: 5,
|
||||
textTransform: 'none'
|
||||
}}>Working</Button >
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{
|
||||
display: "block",
|
||||
height: "calc(100% - 120px)",
|
||||
overflow: "hidden",
|
||||
position: "absolute",
|
||||
width: "100%"
|
||||
}}>
|
||||
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@ -1,39 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useStompClient } from 'react-stomp-hooks';
|
||||
import { RootState } from "../store";
|
||||
import { useWsSubscription } from "../ws/subscriptions";
|
||||
import { set } from "../store/persistent-events-slice";
|
||||
import { EventsObjectListResponse } from "../../types";
|
||||
|
||||
export default function PersistentEventsPage() {
|
||||
const dispatch = useDispatch();
|
||||
const client = useStompClient();
|
||||
const cursor = useSelector((state: RootState) => state.persistentEvents)
|
||||
|
||||
useWsSubscription<EventsObjectListResponse>("/topic/persistent/events", (response) => {
|
||||
dispatch(set(response))
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
|
||||
// Kjør din funksjon her når komponenten lastes inn for første gang
|
||||
// Sjekk om cursor er null
|
||||
if (cursor.items === null && client !== null) {
|
||||
console.log(cursor)
|
||||
// Kjør din funksjon her når cursor er null og client ikke er null
|
||||
client?.publish({
|
||||
destination: "/app/persistent/events"
|
||||
});
|
||||
|
||||
// Alternativt, du kan dispatche en Redux handling her
|
||||
// dispatch(fetchDataAction()); // Eksempel på å dispatche en handling
|
||||
}
|
||||
}, [cursor, client, dispatch]);
|
||||
|
||||
|
||||
return (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
@ -1,193 +0,0 @@
|
||||
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 ContextMenu, { ContextMenuActionEvent, 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, TableRowActionEvents } from "../features/table/table";
|
||||
import MultiListSortedTable from "../features/table/multiListSortedTable";
|
||||
import { setContextMenuVisible, setContextMenuPosition } from "../store/context-menu-slice";
|
||||
import { canConvert, canEncode, canExtract } from "../../fileUtil";
|
||||
import { CoordinatorOperationRequest } from "../features/types";
|
||||
|
||||
|
||||
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<FileInfo|null>(null);
|
||||
const [actionableItems, setActionableItems] = useState<Array<ContextMenuItem>>([]);
|
||||
|
||||
useWsSubscription<IncomingUnprocessedFiles>("/topic/files/unprocessed", (response) => {
|
||||
dispatch(update(response))
|
||||
});
|
||||
|
||||
|
||||
const onItemSelectedEvent: TableRowActionEvents<FileInfo> = {
|
||||
contextMenu: (row: FileInfo, x: number, y: number) => {
|
||||
dispatch(setContextMenuVisible(true));
|
||||
dispatch(setContextMenuPosition({ x: x, y: y }));
|
||||
setActionableItems(getContextMenuFileActionMenuItems(row));
|
||||
},
|
||||
click: function (row: FileInfo): void {
|
||||
},
|
||||
doubleClick: function (row: FileInfo): void {
|
||||
}
|
||||
};
|
||||
|
||||
function getContextMenuFileActionMenuItems(row: FileInfo | null): ContextMenuItem[] {
|
||||
const items: Array<ContextMenuItem> = [
|
||||
{
|
||||
actionIndex: 0,
|
||||
icon: null,
|
||||
text: "All available"
|
||||
},
|
||||
{
|
||||
actionIndex: 1,
|
||||
icon: null,
|
||||
text: "Encode"
|
||||
},
|
||||
{
|
||||
actionIndex: 2,
|
||||
icon: null,
|
||||
text: "Extract"
|
||||
}
|
||||
|
||||
];
|
||||
return items;
|
||||
}
|
||||
|
||||
|
||||
const onContextMenuItemClickedEvent: ContextMenuActionEvent<FileInfo> = {
|
||||
selected: function (actionIndex: number | null, value: FileInfo | null): void {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
const payload = (() => {
|
||||
switch(actionIndex) {
|
||||
case 0: {
|
||||
return {
|
||||
destination: "request/all",
|
||||
file: value.fileName,
|
||||
source: `Web UI @ ${window.location.href}`,
|
||||
mode: "FLOW"
|
||||
} as CoordinatorOperationRequest
|
||||
}
|
||||
case 1: {
|
||||
return {
|
||||
destination: "request/encode",
|
||||
file: value.fileName,
|
||||
source: `Web UI @ ${window.location.href}`,
|
||||
mode: "FLOW"
|
||||
} as CoordinatorOperationRequest
|
||||
}
|
||||
case 2: {
|
||||
return {
|
||||
destination: "request/extract",
|
||||
file: value.fileName,
|
||||
source: `Web UI @ ${window.location.href}`,
|
||||
mode: "FLOW"
|
||||
} as CoordinatorOperationRequest
|
||||
}
|
||||
default: {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
})();
|
||||
if (payload) {
|
||||
client?.publish({
|
||||
destination: "/app/"+payload.destination,
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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} onRowClickedEvent={onItemSelectedEvent} />
|
||||
</Box>
|
||||
</Box>
|
||||
<ContextMenu row={selectedRow} actionItems={actionableItems} onContextMenuItemClicked={onContextMenuItemClickedEvent} />
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
import { configureStore, ThunkAction, Action } from '@reduxjs/toolkit';
|
||||
import composedSlice from './store/composed-slice';
|
||||
import explorerSlice from './store/explorer-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';
|
||||
import workSlice from './store/work-slice';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
composed: composedSlice,
|
||||
explorer: explorerSlice,
|
||||
contextMenu: contextMenuSlice,
|
||||
persistentEvents: persistentEventsSlice,
|
||||
chained: chainedEventsSlice,
|
||||
unprocessedFiles: unprocessedFilesSlice,
|
||||
tasks: tasksSlice,
|
||||
work: workSlice
|
||||
},
|
||||
});
|
||||
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppThunk<ReturnType = void> = ThunkAction<
|
||||
ReturnType,
|
||||
RootState,
|
||||
unknown,
|
||||
Action<string>
|
||||
>;
|
||||
@ -1,40 +0,0 @@
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit"
|
||||
|
||||
export interface EventGroup {
|
||||
referenceId: string,
|
||||
created: number,
|
||||
fileName?: string,
|
||||
events: EventChain[]
|
||||
}
|
||||
|
||||
export interface EventChain {
|
||||
eventId: string,
|
||||
eventName: string,
|
||||
created: number,
|
||||
success: boolean,
|
||||
skipped: boolean,
|
||||
failure: boolean,
|
||||
events: Array<EventChain>
|
||||
}
|
||||
|
||||
export interface EventGroups {
|
||||
groups: Array<EventGroup>
|
||||
}
|
||||
|
||||
const initialState: EventGroups = {
|
||||
groups: []
|
||||
};
|
||||
|
||||
|
||||
const chainedEventsSlice = createSlice({
|
||||
name: "ChainedEvents",
|
||||
initialState,
|
||||
reducers: {
|
||||
set: (state, action: PayloadAction<Array<EventGroup>>) => {
|
||||
state.groups = action.payload;
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const { set } = chainedEventsSlice.actions;
|
||||
export default chainedEventsSlice.reducer;
|
||||
@ -1,23 +0,0 @@
|
||||
import { PayloadAction, createSlice } from "@reduxjs/toolkit"
|
||||
import { EventDataObject } from "../../types"
|
||||
|
||||
interface ComposedState {
|
||||
items: Array<EventDataObject>
|
||||
}
|
||||
|
||||
const initialState: ComposedState = {
|
||||
items: []
|
||||
}
|
||||
|
||||
const composedSlice = createSlice({
|
||||
name: "Composed",
|
||||
initialState,
|
||||
reducers: {
|
||||
updateItems(state, action: PayloadAction<Array<EventDataObject>>) {
|
||||
state.items = action.payload
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const { updateItems } = composedSlice.actions;
|
||||
export default composedSlice.reducer;
|
||||
@ -1,31 +0,0 @@
|
||||
import { PayloadAction, createSlice } from "@reduxjs/toolkit"
|
||||
|
||||
interface ContextMenuPosition {
|
||||
x: number,
|
||||
y: number
|
||||
}
|
||||
|
||||
interface ContextMenuState {
|
||||
visible: boolean,
|
||||
position?: ContextMenuPosition
|
||||
}
|
||||
|
||||
const initialState: ContextMenuState = {
|
||||
visible: false
|
||||
}
|
||||
|
||||
const contextMenuSlice = createSlice({
|
||||
name: 'ContextMenu',
|
||||
initialState,
|
||||
reducers: {
|
||||
setContextMenuVisible(state, action: PayloadAction<boolean>) {
|
||||
state.visible = action.payload
|
||||
},
|
||||
setContextMenuPosition(state, action: PayloadAction<ContextMenuPosition>) {
|
||||
state.position = action.payload
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const { setContextMenuVisible, setContextMenuPosition } = contextMenuSlice.actions;
|
||||
export default contextMenuSlice.reducer;
|
||||
@ -1,29 +0,0 @@
|
||||
import { PayloadAction, createSlice } from "@reduxjs/toolkit"
|
||||
import { ExplorerItem, ExplorerCursor } from "../../types"
|
||||
|
||||
interface ExplorerState {
|
||||
name: string | null
|
||||
path: string | null
|
||||
items: Array<ExplorerItem>
|
||||
}
|
||||
|
||||
const initialState: ExplorerState = {
|
||||
name: null,
|
||||
path: null,
|
||||
items: []
|
||||
}
|
||||
|
||||
const composedSlice = createSlice({
|
||||
name: "Explorer",
|
||||
initialState,
|
||||
reducers: {
|
||||
updateItems(state, action: PayloadAction<ExplorerCursor>) {
|
||||
state.items = action.payload.items;
|
||||
state.name = action.payload.name;
|
||||
state.path = action.payload.path
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
export const { updateItems } = composedSlice.actions;
|
||||
export default composedSlice.reducer;
|
||||
@ -1,26 +0,0 @@
|
||||
import { PayloadAction, createSlice } from "@reduxjs/toolkit"
|
||||
import { EventsObjectList, EventsObjectListResponse } from "../../types";
|
||||
|
||||
interface PersistentEventsState {
|
||||
items: Array<EventsObjectList>
|
||||
lastPull: string|null
|
||||
}
|
||||
|
||||
const initialState: PersistentEventsState = {
|
||||
items: [],
|
||||
lastPull: null
|
||||
}
|
||||
|
||||
const persistentEventSlice = createSlice({
|
||||
name: "PersistentEvents",
|
||||
initialState,
|
||||
reducers: {
|
||||
set(state, action: PayloadAction<EventsObjectListResponse>) {
|
||||
state.items = action.payload.items;
|
||||
state.lastPull = action.payload.lastPull;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const { set } = persistentEventSlice.actions;
|
||||
export default persistentEventSlice.reducer;
|
||||
@ -1,97 +0,0 @@
|
||||
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> {
|
||||
referenceId: string
|
||||
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) => ({
|
||||
referenceId: value.referenceId,
|
||||
title: value.referenceId,
|
||||
items: value.tasks
|
||||
})) ?? []
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
export const { update } = tasksSlice.actions;
|
||||
export default tasksSlice.reducer;
|
||||
@ -1,39 +0,0 @@
|
||||
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;
|
||||
@ -1,85 +0,0 @@
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit"
|
||||
|
||||
export enum WorkStatus {
|
||||
Pending = "Pending",
|
||||
Started = "Started",
|
||||
Working = "Working",
|
||||
Completed = "Completed",
|
||||
Failed = "Failed"
|
||||
}
|
||||
|
||||
export interface ProcesserProgress {
|
||||
progress: number
|
||||
speed: string
|
||||
timeWorkedOn: string
|
||||
timeLeft: string
|
||||
}
|
||||
|
||||
export interface ProcesserEventInfo {
|
||||
referenceId: string
|
||||
eventId: string
|
||||
status: WorkStatus
|
||||
progress: ProcesserProgress
|
||||
inputFile: string
|
||||
outputFiles: Array<string>
|
||||
}
|
||||
|
||||
|
||||
export enum Status {
|
||||
Skipped = 'Skipped',
|
||||
Awaiting = 'Awaiting',
|
||||
NeedsApproval = 'NeedsApproval',
|
||||
Pending = 'Pending',
|
||||
InProgress = 'InProgress',
|
||||
Completed = 'Completed',
|
||||
Failed = "Failed"
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface ContentEventState {
|
||||
referenceId: string
|
||||
title: string
|
||||
encode: Status
|
||||
extract: Status
|
||||
convert: Status
|
||||
created: number
|
||||
encodeWork: ProcesserEventInfo
|
||||
}
|
||||
|
||||
export interface ContentEventStateItems {
|
||||
items: Array<ContentEventState>,
|
||||
encodeWork: { [key: string]: ProcesserEventInfo }
|
||||
|
||||
}
|
||||
|
||||
const initialState: ContentEventStateItems = {
|
||||
items: [],
|
||||
encodeWork: {}
|
||||
}
|
||||
|
||||
const workSlice = createSlice({
|
||||
name: "Work",
|
||||
initialState,
|
||||
reducers: {
|
||||
update(state, action: PayloadAction<Array<ContentEventState>>) {
|
||||
state.items = action.payload.map(item => ({
|
||||
...item,
|
||||
rowId: item.referenceId, // Setter rowId lik referenceId
|
||||
encodeWork: state.encodeWork[item.referenceId] ?? undefined // Reapply encodeWork hvis det finnes
|
||||
}));
|
||||
},
|
||||
updateEncodeProgress(state, action: PayloadAction<ProcesserEventInfo>) {
|
||||
state.encodeWork[action.payload.referenceId] = action.payload;
|
||||
state.items = state.items.map(item =>
|
||||
item.referenceId === action.payload.referenceId
|
||||
? { ...item, encodeWork: action.payload }
|
||||
: item
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
export const { update, updateEncodeProgress } = workSlice.actions;
|
||||
export default workSlice.reducer;
|
||||
@ -1,57 +0,0 @@
|
||||
import * as Stomp from 'stompjs';
|
||||
import SockJS from 'sockjs-client';
|
||||
|
||||
export class WebSocketClient {
|
||||
private stompClient: Stomp.Client | undefined;
|
||||
private subscriptions: { [id: string]: Stomp.Subscription } = {};
|
||||
private isConnecting = false;
|
||||
private connectCallbacks: (() => void)[] = [];
|
||||
private wsUrl: string | undefined;
|
||||
|
||||
public connect(wsUrl: string) {
|
||||
this.wsUrl = wsUrl;
|
||||
if (this.isConnecting) {
|
||||
return;
|
||||
}
|
||||
this.isConnecting = true;
|
||||
const socket = new SockJS(this.wsUrl);
|
||||
this.stompClient = Stomp.over(socket);
|
||||
this.stompClient.connect({}, (frame) => {
|
||||
this.isConnecting = false;
|
||||
this.connectCallbacks.forEach((cb) => cb());
|
||||
});
|
||||
}
|
||||
|
||||
public subscribe<T>(path: string, processData: (data: T) => void) {
|
||||
const subscription = this.stompClient?.subscribe(path, (data) => {
|
||||
const converted = JSON.parse(data.body) as T;
|
||||
processData(converted);
|
||||
});
|
||||
if (subscription !== undefined) {
|
||||
this.subscriptions[path] = subscription;
|
||||
}
|
||||
}
|
||||
|
||||
public unsubscribe(path: string) {
|
||||
const subscription = this.subscriptions[path];
|
||||
if (subscription) {
|
||||
subscription.unsubscribe();
|
||||
delete this.subscriptions[path];
|
||||
}
|
||||
}
|
||||
|
||||
public onConnect(callback: () => void) {
|
||||
if (this.isConnecting) {
|
||||
this.connectCallbacks.push(callback);
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
public disconnect() {
|
||||
this.stompClient?.disconnect(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export const webSocketClient = new WebSocketClient();
|
||||
@ -1,18 +0,0 @@
|
||||
import { IMessage } from "@stomp/stompjs/esm6";
|
||||
import { useEffect } from 'react';
|
||||
import { webSocketClient } from './client';
|
||||
import { useSubscription } from "react-stomp-hooks";
|
||||
|
||||
|
||||
|
||||
export function useWsSubscription<T>(path: string, processData: (data: T) => void,
|
||||
) {
|
||||
return useSubscription(path, (payload: IMessage) => {
|
||||
const converted = toType<T>(payload);
|
||||
processData(converted)
|
||||
})
|
||||
}
|
||||
|
||||
function toType<T>(data: IMessage): T {
|
||||
return JSON.parse(data.body) as T;
|
||||
}
|
||||
1
apps/ui/web/src/assets/react.svg
Normal file
1
apps/ui/web/src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
56
apps/ui/web/src/components/BreadcrumbPath.tsx
Normal file
56
apps/ui/web/src/components/BreadcrumbPath.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import HomeIcon from "@mui/icons-material/Home"
|
||||
import { Breadcrumbs, Chip } from "@mui/material"
|
||||
|
||||
export interface BreadcrumbPathProps {
|
||||
path: string
|
||||
onNavigate: (path: string) => void
|
||||
}
|
||||
|
||||
function splitPath(path: string): string[] {
|
||||
if (!path || path === "/") return []
|
||||
return path.split("/").filter(Boolean)
|
||||
}
|
||||
|
||||
function buildPath(parts: string[], index: number) {
|
||||
return "/" + parts.slice(0, index + 1).join("/")
|
||||
}
|
||||
|
||||
export function BreadcrumbPath({ path, onNavigate }: BreadcrumbPathProps) {
|
||||
const segments = splitPath(path)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Chip
|
||||
icon={<HomeIcon />}
|
||||
label="Home"
|
||||
clickable
|
||||
onClick={() => onNavigate("/home")}
|
||||
sx={{ fontWeight: 600 }}
|
||||
/>
|
||||
<Breadcrumbs separator="›">
|
||||
<Chip
|
||||
label={"/"}
|
||||
clickable
|
||||
onClick={() => onNavigate("/")}
|
||||
variant="outlined"
|
||||
sx={{ fontWeight: 500 }}
|
||||
/>
|
||||
|
||||
{segments.map((segment, index) => {
|
||||
const p = buildPath(segments, index)
|
||||
return (
|
||||
<Chip
|
||||
key={p}
|
||||
label={segment}
|
||||
clickable
|
||||
onClick={() => onNavigate(p)}
|
||||
variant="outlined"
|
||||
sx={{ fontWeight: 500 }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Breadcrumbs>
|
||||
</>
|
||||
|
||||
)
|
||||
}
|
||||
45
apps/ui/web/src/components/ConfirmationDialog.tsx
Normal file
45
apps/ui/web/src/components/ConfirmationDialog.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Typography
|
||||
} from "@mui/material"
|
||||
|
||||
export interface GenericConfirmDialogProps {
|
||||
open: boolean
|
||||
title: string
|
||||
message: string
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
confirmColor?: "primary" | "error" | "warning" | "success" | "info"
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function ConfirmationDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = "OK",
|
||||
cancelLabel = "Avbryt",
|
||||
confirmColor = "primary",
|
||||
onConfirm,
|
||||
onCancel
|
||||
}: GenericConfirmDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onCancel}>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>{message}</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onCancel}>{cancelLabel}</Button>
|
||||
<Button color={confirmColor} onClick={onConfirm}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
23
apps/ui/web/src/components/DetailsButton.tsx
Normal file
23
apps/ui/web/src/components/DetailsButton.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import InfoIcon from "@mui/icons-material/Info"
|
||||
import { Button } from "@mui/material"
|
||||
|
||||
export function DetailsButton({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={onClick}
|
||||
startIcon={<InfoIcon />}
|
||||
sx={{
|
||||
textTransform: "none",
|
||||
borderColor: "primary.main",
|
||||
color: "primary.main",
|
||||
paddingY: 0.25,
|
||||
paddingX: 1,
|
||||
minHeight: "28px",
|
||||
}}
|
||||
>
|
||||
Detaljer
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
59
apps/ui/web/src/components/FileContextMenu.tsx
Normal file
59
apps/ui/web/src/components/FileContextMenu.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import { Menu, MenuItem } from "@mui/material";
|
||||
import type { FileAction, IFile, MediaAction } from "../types/files";
|
||||
|
||||
export interface FileContextMenuProps {
|
||||
file: IFile | null
|
||||
position: { mouseX: number; mouseY: number } | null
|
||||
onClose: () => void
|
||||
onMediaAction: (action: MediaAction, file: IFile) => void
|
||||
onFileAction: (action: FileAction, file: IFile) => void
|
||||
onCopyPath: (file: IFile) => void
|
||||
}
|
||||
|
||||
export function FileContextMenu({
|
||||
file,
|
||||
position,
|
||||
onClose,
|
||||
onMediaAction,
|
||||
onFileAction,
|
||||
onCopyPath
|
||||
}: FileContextMenuProps) {
|
||||
if (!file) return null
|
||||
|
||||
return (
|
||||
<Menu
|
||||
open={!!position}
|
||||
onClose={onClose}
|
||||
anchorReference="anchorPosition"
|
||||
anchorPosition={
|
||||
position
|
||||
? { top: position.mouseY, left: position.mouseX }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* Media actions */}
|
||||
{file.actions.mediaActions.map(action => (
|
||||
<MenuItem
|
||||
key={action.id}
|
||||
onClick={() => onMediaAction(action, file)}
|
||||
>
|
||||
{action.title}
|
||||
</MenuItem>
|
||||
))}
|
||||
|
||||
{/* File actions */}
|
||||
{file.actions.fileActions.map(action => (
|
||||
<MenuItem
|
||||
key={action.id}
|
||||
onClick={() => onFileAction(action, file)}
|
||||
sx={action.id === "Delete" ? { color: "error.main" } : undefined}
|
||||
>
|
||||
{action.title}
|
||||
</MenuItem>
|
||||
))}
|
||||
|
||||
{/* Always available */}
|
||||
<MenuItem onClick={() => onCopyPath(file)}>Kopier sti</MenuItem>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
33
apps/ui/web/src/components/FileList.tsx
Normal file
33
apps/ui/web/src/components/FileList.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import FolderIcon from "@mui/icons-material/Folder"
|
||||
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"
|
||||
import { List, ListItemButton, ListItemIcon, ListItemText } from "@mui/material"
|
||||
import type { IFile } from "../types/files"
|
||||
import { normalDate } from "../util"
|
||||
|
||||
export interface FileListProps {
|
||||
files: IFile[]
|
||||
onOpenFolder: (file: IFile) => void
|
||||
onContextMenu: (event: React.MouseEvent<HTMLElement>, file: IFile) => void
|
||||
}
|
||||
|
||||
export function FileList({ files, onOpenFolder, onContextMenu }: FileListProps) {
|
||||
return (
|
||||
<List sx={{ bgcolor: "background.paper" }}>
|
||||
{files.map((f) => (
|
||||
<ListItemButton
|
||||
key={f.uri}
|
||||
onClick={() => f.type === "Folder" && onOpenFolder(f)}
|
||||
onContextMenu={(e) => onContextMenu(e, f)}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{f.type === "Folder" ? <FolderIcon /> : <InsertDriveFileIcon />}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={f.name}
|
||||
secondary={normalDate.format(new Date(f.created))}
|
||||
/>
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
)
|
||||
}
|
||||
128
apps/ui/web/src/components/FilterChips.tsx
Normal file
128
apps/ui/web/src/components/FilterChips.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
import { Box, Chip, Paper, TextField } from "@mui/material"
|
||||
import { useMemo, useState } from "react"
|
||||
|
||||
export interface FilterChipsProps {
|
||||
value: string[]
|
||||
onChange: (filters: string[]) => void
|
||||
|
||||
onBeforeAdd?: (token: string, current: string[]) => string[] | null
|
||||
onBeforeRemove?: (token: string, current: string[]) => string[] | null
|
||||
|
||||
suggestions?: string[]
|
||||
keySuggestions?: string[]
|
||||
keyLabel?: string
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function FilterChips({
|
||||
value,
|
||||
onChange,
|
||||
onBeforeAdd,
|
||||
onBeforeRemove,
|
||||
suggestions = [],
|
||||
keySuggestions = [],
|
||||
keyLabel = "Key",
|
||||
placeholder = "Legg til filter…"
|
||||
}: FilterChipsProps) {
|
||||
|
||||
const [input, setInput] = useState("")
|
||||
|
||||
const allSuggestions = useMemo(() => {
|
||||
const lower = input.toLowerCase()
|
||||
const base = suggestions.filter(s => s.toLowerCase().includes(lower))
|
||||
const keys = keySuggestions
|
||||
.filter(k => k.toLowerCase().includes(lower))
|
||||
.map(k => `key:${k}`)
|
||||
return [...base, ...keys]
|
||||
}, [input, suggestions, keySuggestions])
|
||||
|
||||
const addFilter = (token: string) => {
|
||||
let next = [...value]
|
||||
|
||||
if (onBeforeAdd) {
|
||||
const result = onBeforeAdd(token, next)
|
||||
if (result === null) {
|
||||
setInput("")
|
||||
return
|
||||
}
|
||||
next = result
|
||||
}
|
||||
|
||||
if (!next.includes(token)) {
|
||||
next.push(token)
|
||||
}
|
||||
|
||||
onChange(next)
|
||||
setInput("")
|
||||
}
|
||||
|
||||
const removeFilter = (token: string) => {
|
||||
let next = [...value]
|
||||
|
||||
if (onBeforeRemove) {
|
||||
const result = onBeforeRemove(token, next)
|
||||
if (result === null) return
|
||||
next = result
|
||||
} else {
|
||||
next = next.filter(f => f !== token)
|
||||
}
|
||||
|
||||
onChange(next)
|
||||
}
|
||||
|
||||
const prettyLabel = (token: string) => {
|
||||
if (token.startsWith("key:")) {
|
||||
return `${keyLabel}: ${token.substring(4)}`
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
label={placeholder}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Enter" && input.trim()) {
|
||||
addFilter(input.trim())
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Forslag */}
|
||||
{input && allSuggestions.length > 0 && (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 1,
|
||||
display: "flex",
|
||||
gap: 1,
|
||||
flexWrap: "wrap",
|
||||
background: "rgba(0,0,0,0.04)"
|
||||
}}
|
||||
>
|
||||
{allSuggestions.map(s => (
|
||||
<Chip
|
||||
key={s}
|
||||
label={prettyLabel(s)}
|
||||
variant="outlined"
|
||||
onClick={() => addFilter(s)}
|
||||
/>
|
||||
))}
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Aktive chips */}
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
{value.map(f => (
|
||||
<Chip
|
||||
key={f}
|
||||
label={prettyLabel(f)}
|
||||
onDelete={() => removeFilter(f)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
126
apps/ui/web/src/components/JsonViewer.tsx
Normal file
126
apps/ui/web/src/components/JsonViewer.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight"
|
||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore"
|
||||
import { Box, IconButton, Typography } from "@mui/material"
|
||||
import { useState } from "react"
|
||||
import { JSON_VIEWER_CONFIG as C } from "../theme/theme"
|
||||
import { deepUnwrapJson } from "../util"
|
||||
|
||||
export interface JsonViewerProps {
|
||||
value: unknown
|
||||
level?: number
|
||||
}
|
||||
|
||||
export function JsonViewer({ value, level = 0 }: JsonViewerProps) {
|
||||
const unwrapped = deepUnwrapJson(value)
|
||||
const indent = level * C.indentPx
|
||||
|
||||
const renderPrimitive = (v: unknown) => {
|
||||
if (v === null)
|
||||
return <Typography color={C.colors.null}>null</Typography>
|
||||
|
||||
if (typeof v === "number")
|
||||
return <Typography color={C.colors.number}>{v}</Typography>
|
||||
|
||||
if (typeof v === "boolean")
|
||||
return <Typography color={C.colors.boolean}>{String(v)}</Typography>
|
||||
|
||||
if (typeof v === "string")
|
||||
return (
|
||||
<Typography color={C.colors.string} sx={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
||||
"{v}"
|
||||
</Typography>
|
||||
)
|
||||
|
||||
return (
|
||||
<Typography color={C.colors.unknown}>
|
||||
{String(v)}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
|
||||
// Primitive
|
||||
if (
|
||||
unwrapped === null ||
|
||||
typeof unwrapped === "number" ||
|
||||
typeof unwrapped === "boolean" ||
|
||||
typeof unwrapped === "string"
|
||||
) {
|
||||
return (
|
||||
<Box sx={{ ml: indent }}>
|
||||
{renderPrimitive(unwrapped)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Array
|
||||
if (Array.isArray(unwrapped)) {
|
||||
const [open, setOpen] = useState(true)
|
||||
|
||||
return (
|
||||
<Box sx={{ ml: indent }}>
|
||||
<Box display="flex" alignItems="center">
|
||||
<IconButton size="small" onClick={() => setOpen(!open)}>
|
||||
{open ? <ExpandMoreIcon /> : <ChevronRightIcon />}
|
||||
</IconButton>
|
||||
<Typography color={C.colors.bracket}>[</Typography>
|
||||
</Box>
|
||||
|
||||
{open &&
|
||||
unwrapped.map((item, i) => (
|
||||
<JsonViewer key={i} value={item} level={level + 1} />
|
||||
))}
|
||||
|
||||
<Typography sx={{ ml: indent }} color={C.colors.bracket}>]</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Object
|
||||
if (typeof unwrapped === "object") {
|
||||
const [open, setOpen] = useState(true)
|
||||
const entries = Object.entries(unwrapped as Record<string, unknown>)
|
||||
|
||||
return (
|
||||
<Box sx={{ ml: indent }}>
|
||||
<Box display="flex" alignItems="center">
|
||||
<IconButton size="small" onClick={() => setOpen(!open)}>
|
||||
{open ? <ExpandMoreIcon /> : <ChevronRightIcon />}
|
||||
</IconButton>
|
||||
<Typography color={C.colors.bracket}>{"{"}</Typography>
|
||||
</Box>
|
||||
|
||||
{open &&
|
||||
entries.map(([key, val]) => {
|
||||
const isPrimitive =
|
||||
val === null ||
|
||||
typeof val === "string" ||
|
||||
typeof val === "number" ||
|
||||
typeof val === "boolean"
|
||||
|
||||
return (
|
||||
<Box key={key} sx={{ ml: C.indentPx, display: "flex" }}>
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{ color: C.colors.key, mr: 1 }}
|
||||
>
|
||||
{key}:
|
||||
</Typography>
|
||||
|
||||
{isPrimitive ? (
|
||||
<Box sx={{ flex: 1 }}>{renderPrimitive(val)}</Box>
|
||||
) : (
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<JsonViewer value={val} level={level + 1} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
|
||||
<Typography sx={{ ml: indent }} color={C.colors.bracket}>{"}"}</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
22
apps/ui/web/src/components/LoadingToast.tsx
Normal file
22
apps/ui/web/src/components/LoadingToast.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { Alert, Snackbar } from "@mui/material"
|
||||
|
||||
export interface LoadingToastProps {
|
||||
open: boolean
|
||||
}
|
||||
|
||||
export function LoadingToast({ open }: LoadingToastProps) {
|
||||
return (
|
||||
<Snackbar
|
||||
open={open}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
|
||||
>
|
||||
<Alert
|
||||
severity="info"
|
||||
variant="filled"
|
||||
sx={{ display: "flex", alignItems: "center" }}
|
||||
>
|
||||
Laster innhold…
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
)
|
||||
}
|
||||
23
apps/ui/web/src/components/NodeBox.tsx
Normal file
23
apps/ui/web/src/components/NodeBox.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { Box, Typography } from "@mui/material"
|
||||
|
||||
export function NodeBox({ icon, label, healthy }: any) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: "1px solid",
|
||||
borderColor: healthy ? "success.main" : "error.main",
|
||||
minWidth: 120,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<Typography variant="subtitle1" sx={{ mt: 1 }}>
|
||||
{label}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
70
apps/ui/web/src/components/Paginator.tsx
Normal file
70
apps/ui/web/src/components/Paginator.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import Box from "@mui/material/Box"
|
||||
import MenuItem from "@mui/material/MenuItem"
|
||||
import Pagination from "@mui/material/Pagination"
|
||||
import Select from "@mui/material/Select"
|
||||
import Typography from "@mui/material/Typography"
|
||||
|
||||
export interface PaginatorProps {
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
onPageChange: (page: number) => void
|
||||
onSizeChange: (size: number) => void
|
||||
}
|
||||
|
||||
|
||||
export function Paginator({
|
||||
page,
|
||||
size,
|
||||
total,
|
||||
onPageChange,
|
||||
onSizeChange
|
||||
}: PaginatorProps) {
|
||||
const totalPages = Math.ceil(total / size)
|
||||
|
||||
// Skjul paginator hvis det bare er én side
|
||||
|
||||
|
||||
const start = page * size + 1
|
||||
const end = Math.min(total, (page + 1) * size)
|
||||
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
gap={2}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
{/* Page size dropdown */}
|
||||
<Select
|
||||
size="small"
|
||||
value={size}
|
||||
onChange={e => onSizeChange(Number(e.target.value))}
|
||||
>
|
||||
{[10, 25, 50, 100].map(opt => (
|
||||
<MenuItem key={opt} value={opt}>
|
||||
{opt} per side
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{/* Sideknapper */}
|
||||
<Pagination
|
||||
count={totalPages}
|
||||
page={page + 1}
|
||||
onChange={(_, value) => onPageChange(value - 1)}
|
||||
color="primary"
|
||||
shape="rounded"
|
||||
size="small"
|
||||
/>
|
||||
|
||||
{/* Range info */}
|
||||
<Typography variant="body2">
|
||||
{start}–{end} av {total}
|
||||
</Typography>
|
||||
|
||||
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
121
apps/ui/web/src/components/Sidebar.tsx
Normal file
121
apps/ui/web/src/components/Sidebar.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
import {
|
||||
Box,
|
||||
Drawer,
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText
|
||||
} from "@mui/material"
|
||||
import { type JSX } from "react"
|
||||
import { useSidebarMenu } from "../menu/sidebar-menu-items"
|
||||
import type { MenuItem } from "../types/MenuItem"
|
||||
|
||||
type SidebarProps = {
|
||||
open?: boolean
|
||||
topOffset?: number
|
||||
}
|
||||
|
||||
|
||||
export function Sidebar({ open, topOffset = 64 }: SidebarProps): JSX.Element {
|
||||
const { topMenu, bottomMenu } = useSidebarMenu()
|
||||
const drawerWidth = open ? 260 : 64
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
PaperProps={{
|
||||
sx: {
|
||||
width: drawerWidth,
|
||||
overflowX: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
transition: (theme) =>
|
||||
theme.transitions.create("width", {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.standard
|
||||
}),
|
||||
top: `${topOffset}px`,
|
||||
height: `calc(100% - ${topOffset}px)`
|
||||
}
|
||||
}}
|
||||
>
|
||||
<List>
|
||||
{topMenu.map((item: MenuItem) => (
|
||||
<ListItemButton
|
||||
key={item.id}
|
||||
onClick={item.onClick}
|
||||
sx={{
|
||||
height: 48,
|
||||
px: open ? 2.5 : 2, // ← matcher hamburger offset
|
||||
justifyContent: open ? "initial" : "flex-start"
|
||||
}}
|
||||
>
|
||||
<ListItemIcon
|
||||
sx={{
|
||||
minWidth: 0,
|
||||
mr: open ? 2 : 0,
|
||||
justifyContent: "flex-start", // ← viktig
|
||||
pl: open ? 0 : 0.5 // ← finjustering
|
||||
}}
|
||||
>
|
||||
|
||||
{item.icon}
|
||||
</ListItemIcon>
|
||||
|
||||
{open && (
|
||||
<ListItemText
|
||||
primary={item.label}
|
||||
sx={{
|
||||
m: 0
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ListItemButton>
|
||||
|
||||
))}
|
||||
</List>
|
||||
|
||||
{bottomMenu.length > 0 && (
|
||||
<>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
<List>
|
||||
{bottomMenu.map((item: MenuItem) => (
|
||||
<ListItemButton
|
||||
key={item.id}
|
||||
onClick={item.onClick}
|
||||
sx={{
|
||||
height: 48,
|
||||
px: open ? 2.5 : 2, // ← matcher hamburger offset
|
||||
justifyContent: open ? "initial" : "flex-start"
|
||||
}}
|
||||
>
|
||||
<ListItemIcon
|
||||
sx={{
|
||||
minWidth: 0,
|
||||
mr: open ? 2 : 0,
|
||||
justifyContent: "flex-start", // ← viktig
|
||||
pl: open ? 0 : 0.5 // ← finjustering
|
||||
}}
|
||||
>
|
||||
|
||||
{item.icon}
|
||||
</ListItemIcon>
|
||||
|
||||
{open && (
|
||||
<ListItemText
|
||||
primary={item.label}
|
||||
sx={{
|
||||
m: 0
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ListItemButton>
|
||||
|
||||
))}
|
||||
</List>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
17
apps/ui/web/src/components/StatusLine.tsx
Normal file
17
apps/ui/web/src/components/StatusLine.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { Box } from "@mui/material"
|
||||
|
||||
export function StatusLine({ ok }: { ok: boolean }) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
height: 4,
|
||||
marginTop: 1,
|
||||
marginBottom: 1,
|
||||
width: "100%",
|
||||
backgroundColor: ok ? "success.main" : "error.main",
|
||||
transition: "background-color 0.3s",
|
||||
borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user