This commit is contained in:
Brage Skjønborg 2026-02-01 18:06:30 +01:00
parent 701c939e8d
commit e69f8b7ff8
157 changed files with 7805 additions and 34310 deletions

View File

@ -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()
}

View File

@ -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) }
}

View File

@ -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()
}
}

View File

@ -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()}" }
}

View File

@ -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()
}
}

View File

@ -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"
}
}

View File

@ -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)
}
}

View File

@ -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()
}
}

View File

@ -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
)
}

View File

@ -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,
)

View File

@ -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,
)

View File

@ -0,0 +1,6 @@
package no.iktdev.mediaprocessing.ui.dto
data class SSEMessage(
val name: String,
val data: Any
)

View File

@ -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
) {
}

View File

@ -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,
)

View File

@ -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
)

View File

@ -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?>
)

View File

@ -0,0 +1,7 @@
package no.iktdev.mediaprocessing.ui.dto.health
enum class CoordinatorHealthStatus {
HEALTHY,
DEGRADED,
UNHEALTHY
}

View File

@ -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
)

View File

@ -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
)

View File

@ -0,0 +1,6 @@
package no.iktdev.mediaprocessing.ui.dto.rate
data class EventRate(
val lastMinute: Int,
val lastFiveMinutes: Int
)

View File

@ -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
}

View File

@ -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
) {
}

View File

@ -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
)

View File

@ -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")
}
}
}

View File

@ -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()
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -0,0 +1,4 @@
package no.iktdev.mediaprocessing.ui
import no.iktdev.mediaprocessing.ui.dto.UiTask

View 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

View File

@ -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

View 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

View File

@ -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?

View File

@ -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 cant go back!**
If you arent 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 youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt 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...
},
},
])
```

View 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
View 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>

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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

View File

@ -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"
}

View File

@ -1,3 +0,0 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View 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

View File

@ -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;
}

View File

@ -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();
});

View File

@ -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
}
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Box sx={{
height: 70,
display: "flex",
alignItems: "center",
paddingLeft: 1,
backgroundColor: theme.palette.action.selected
}}>
<IconButton onClick={() => window.location.href = "/"} sx={{
...iconHeight
}}>
<AppsIcon />
</IconButton>
<IconButton onClick={() => window.location.href = "/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>
</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>
);
interface AppLayoutProps {
children: React.ReactNode
}
export default App;
export function AppLayout({ children }: AppLayoutProps) {
const [sidebarOpen, setSidebarOpen] = useState(false)
const drawerWidth = sidebarOpen ? 260 : 64
return (
<Box
sx={{
display: "flex",
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>
)
}
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

View 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()
}

View 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`)
}

View 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)}`)
}

View File

@ -0,0 +1,6 @@
import type { CoordinatorHealth } from "../types/backendTypes";
import { apiGet } from "./client";
export function getCoordinatorHealth() {
return apiGet<CoordinatorHealth>("/health")
}

View 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
)
}

View 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")
}
}

View 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)
}
}

View File

@ -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>
);
}

View File

@ -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>
)}
</>
)
}

View File

@ -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>
</>
)
}

View File

@ -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>
);
}*/

View File

@ -1,9 +0,0 @@
export default function Footer() {
return (
<>
<div></div>
</>
)
}

View File

@ -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>
)
}

View File

@ -1,6 +0,0 @@
export default function ExpandableTable() {
return <></>
} // ExpandableTable
// ExpandableTable

View File

@ -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 (
<></>
);
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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);
});
}

View File

@ -1,6 +0,0 @@
export interface CoordinatorOperationRequest {
destination: string;
file: string;
source: string;
mode: "FLOW" | "MANUAL";
}

View File

@ -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);
});
};

View File

@ -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;

View File

@ -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>
</>
)
}

View File

@ -1,4 +0,0 @@
.thicc-link {
stroke-width: 3;
stroke: black!important;
}

View File

@ -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} />
</>
)
}

View File

@ -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} />
</>
)
}

View File

@ -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>
)
}

View File

@ -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 (
<></>
)
}

View File

@ -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} />
</>
)
}

View File

@ -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>
>;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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();

View File

@ -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;
}

View 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

View 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>
</>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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