diff --git a/UIPlugin/pom.xml b/UIPlugin/pom.xml new file mode 100644 index 0000000..61525ba --- /dev/null +++ b/UIPlugin/pom.xml @@ -0,0 +1,157 @@ + + + 4.0.0 + + me.egg82 + fetcharr-parent + 1.0.0-SNAPSHOT + + + fetcharr-ui + ${ui.version} + + + 21 + 21 + UTF-8 + + + + + + src/main/resources + true + + **/*.properties + **/*.yaml + **/*.yml + + + + + src/main/resources + false + + **/*.html + **/*.css + **/*.js + + + + + src/main/java + + + + org.codehaus.mojo + versions-maven-plugin + 2.21.0 + + file:///${project.basedir}/../versions.xml + + + + + maven-compiler-plugin + 3.15.0 + + 21 + 21 + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.1 + + + package + + shade + + + ${project.build.directory}/dependency-reduced-pom.xml + + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + LICENSE-2.0.txt + + + + + + + + + + + + + egg82-repo-releases + https://repo.egg82.me/releases/ + + + egg82-repo-snapshots + https://repo.egg82.me/snapshots/ + + + + + + me.egg82 + fetcharr-api + ${api.version} + provided + + + me.egg82 + arr-lib + ${lib.version} + provided + + + + org.jetbrains + annotations + ${jetbrains.version} + provided + + + + org.slf4j + slf4j-api + ${slf4j.version} + provided + + + + com.sasorio + event-api + ${event.version} + provided + + + + org.spongepowered + configurate-core + ${configurate.version} + provided + + + org.spongepowered + configurate-yaml + ${configurate.version} + provided + + + diff --git a/UIPlugin/src/main/java/me/egg82/fuiplugin/EventEntry.java b/UIPlugin/src/main/java/me/egg82/fuiplugin/EventEntry.java new file mode 100644 index 0000000..9df4c55 --- /dev/null +++ b/UIPlugin/src/main/java/me/egg82/fuiplugin/EventEntry.java @@ -0,0 +1,170 @@ +package me.egg82.fuiplugin; + +import me.egg82.arr.lidarr.v1.schema.ArtistResource; +import me.egg82.arr.radarr.v3.schema.MovieResource; +import me.egg82.arr.sonarr.v3.schema.SeriesResource; +import me.egg82.fetcharr.api.event.FetcharrEvent; +import me.egg82.fetcharr.api.event.update.AbstractUpdaterEvent; +import me.egg82.fetcharr.api.event.update.lidarr.*; +import me.egg82.fetcharr.api.event.update.radarr.*; +import me.egg82.fetcharr.api.event.update.sonarr.*; +import me.egg82.fetcharr.api.event.update.whisparr.*; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.time.Instant; + +public record EventEntry( + @NotNull Instant time, + @NotNull String type, + @NotNull String service, + @NotNull String updaterUrl, + @NotNull String title, + boolean cancelled +) { + public static @NotNull EventEntry from(@NotNull FetcharrEvent event) { + Instant time = Instant.now(); + String updaterUrl = ""; + + if (event instanceof AbstractUpdaterEvent ae) { + try { + updaterUrl = ae.updater().config().url(); + } catch (Exception ignored) { } + } + + // Radarr + if (event instanceof RadarrUpdateMovieEvent e) { + return new EventEntry(time, "RadarrUpdateMovie", "radarr", updaterUrl, radarrTitle(e.resource()), false); + } + if (event instanceof RadarrSelectMovieEvent e) { + return new EventEntry(time, "RadarrSelectMovie", "radarr", updaterUrl, radarrTitle(e.resource()), false); + } + if (event instanceof RadarrSkipMovieSelectionEvent e) { + return new EventEntry(time, "RadarrSkipMovie", "radarr", updaterUrl, + radarrTitle(e.resource()) + " [" + e.reason().name() + "]", true); + } + if (event instanceof RadarrFetchMovieEvent) { + return new EventEntry(time, "RadarrFetchMovie", "radarr", updaterUrl, "fetched from API", false); + } + if (event instanceof RadarrSearchEvent e) { + int count = e.resources().size(); + return new EventEntry(time, "RadarrSearch", "radarr", updaterUrl, count + " movie" + (count != 1 ? "s" : ""), false); + } + + // Sonarr + if (event instanceof SonarrUpdateSeriesEvent e) { + return new EventEntry(time, "SonarrUpdateSeries", "sonarr", updaterUrl, sonarrTitle(e.resource()), false); + } + if (event instanceof SonarrSelectSeriesEvent e) { + return new EventEntry(time, "SonarrSelectSeries", "sonarr", updaterUrl, sonarrTitle(e.resource()), false); + } + if (event instanceof SonarrSkipSeriesSelectionEvent e) { + return new EventEntry(time, "SonarrSkipSeries", "sonarr", updaterUrl, + sonarrTitle(e.resource()) + " [" + e.reason().name() + "]", true); + } + if (event instanceof SonarrFetchEpisodesEvent e) { + return new EventEntry(time, "SonarrFetchEpisodes", "sonarr", updaterUrl, sonarrTitle(e.series()), false); + } + if (event instanceof SonarrFetchSeriesEvent) { + return new EventEntry(time, "SonarrFetchSeries", "sonarr", updaterUrl, "fetched from API", false); + } + if (event instanceof SonarrSearchEvent e) { + int count = e.resources().size(); + return new EventEntry(time, "SonarrSearch", "sonarr", updaterUrl, count + " series", false); + } + + // Lidarr + if (event instanceof LidarrUpdateArtistEvent e) { + return new EventEntry(time, "LidarrUpdateArtist", "lidarr", updaterUrl, lidarrTitle(e.resource()), false); + } + if (event instanceof LidarrSelectArtistEvent e) { + return new EventEntry(time, "LidarrSelectArtist", "lidarr", updaterUrl, lidarrTitle(e.resource()), false); + } + if (event instanceof LidarrSkipArtistSelectionEvent e) { + return new EventEntry(time, "LidarrSkipArtist", "lidarr", updaterUrl, + lidarrTitle(e.resource()) + " [" + e.reason().name() + "]", true); + } + if (event instanceof LidarrFetchAlbumsEvent e) { + return new EventEntry(time, "LidarrFetchAlbums", "lidarr", updaterUrl, lidarrTitle(e.artist()), false); + } + if (event instanceof LidarrFetchTracksEvent e) { + return new EventEntry(time, "LidarrFetchTracks", "lidarr", updaterUrl, lidarrTitle(e.artist()), false); + } + if (event instanceof LidarrFetchArtistEvent) { + return new EventEntry(time, "LidarrFetchArtist", "lidarr", updaterUrl, "fetched from API", false); + } + if (event instanceof LidarrSearchEvent e) { + int count = e.resources().size(); + return new EventEntry(time, "LidarrSearch", "lidarr", updaterUrl, count + " artist" + (count != 1 ? "s" : ""), false); + } + + // Whisparr + if (event instanceof WhisparrUpdateMovieEvent e) { + return new EventEntry(time, "WhisparrUpdateMovie", "whisparr", updaterUrl, whisparrTitle(e.resource()), false); + } + if (event instanceof WhisparrSelectMovieEvent e) { + return new EventEntry(time, "WhisparrSelectMovie", "whisparr", updaterUrl, whisparrTitle(e.resource()), false); + } + if (event instanceof WhisparrSkipMovieSelectionEvent e) { + return new EventEntry(time, "WhisparrSkipMovie", "whisparr", updaterUrl, + whisparrTitle(e.resource()) + " [" + e.reason().name() + "]", true); + } + if (event instanceof WhisparrFetchMovieEvent) { + return new EventEntry(time, "WhisparrFetchMovie", "whisparr", updaterUrl, "fetched from API", false); + } + if (event instanceof WhisparrSearchEvent e) { + int count = e.resources().size(); + return new EventEntry(time, "WhisparrSearch", "whisparr", updaterUrl, count + " movie" + (count != 1 ? "s" : ""), false); + } + + return new EventEntry(time, event.eventType().getSimpleName(), "unknown", updaterUrl, "", false); + } + + private static @NotNull String radarrTitle(@Nullable MovieResource r) { + if (r == null) return "Unknown"; + String title = r.getTitle(); + if (title == null || title.isBlank()) return "Unknown"; + Integer year = r.getYear(); + return (year != null && year > 0) ? title + " (" + year + ")" : title; + } + + private static @NotNull String sonarrTitle(@Nullable SeriesResource r) { + if (r == null) return "Unknown"; + String title = r.getTitle(); + return (title != null && !title.isBlank()) ? title : "Unknown"; + } + + private static @NotNull String lidarrTitle(@Nullable ArtistResource r) { + if (r == null) return "Unknown"; + String name = r.getArtistName(); + return (name != null && !name.isBlank()) ? name : "Unknown"; + } + + private static @NotNull String whisparrTitle(@Nullable me.egg82.arr.whisparr.v3.schema.MovieResource r) { + if (r == null) return "Unknown"; + String title = r.getTitle(); + if (title == null || title.isBlank()) return "Unknown"; + Integer year = r.getYear(); + return (year != null && year > 0) ? title + " (" + year + ")" : title; + } + + /** Serialize to a JSON object string (no external library needed). */ + public @NotNull String toJson() { + return "{" + + "\"time\":\"" + jsonEscape(time.toString()) + "\"," + + "\"type\":\"" + jsonEscape(type) + "\"," + + "\"service\":\"" + jsonEscape(service) + "\"," + + "\"updaterUrl\":\"" + jsonEscape(updaterUrl) + "\"," + + "\"title\":\"" + jsonEscape(title) + "\"," + + "\"cancelled\":" + cancelled + + "}"; + } + + private static @NotNull String jsonEscape(@NotNull String s) { + return s.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } +} diff --git a/UIPlugin/src/main/java/me/egg82/fuiplugin/EventLog.java b/UIPlugin/src/main/java/me/egg82/fuiplugin/EventLog.java new file mode 100644 index 0000000..c448bb4 --- /dev/null +++ b/UIPlugin/src/main/java/me/egg82/fuiplugin/EventLog.java @@ -0,0 +1,40 @@ +package me.egg82.fuiplugin; + +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; + +public class EventLog { + private final int maxSize; + private final Deque entries; + + public EventLog(int maxSize) { + this.maxSize = maxSize; + this.entries = new ArrayDeque<>(maxSize); + } + + public synchronized void add(@NotNull EventEntry entry) { + if (entries.size() >= maxSize) { + entries.pollFirst(); + } + entries.addLast(entry); + } + + public synchronized @NotNull List snapshot() { + return new ArrayList<>(entries); + } + + public @NotNull String toJson() { + List snapshot = snapshot(); + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < snapshot.size(); i++) { + if (i > 0) sb.append(","); + sb.append(snapshot.get(i).toJson()); + } + sb.append("]"); + return sb.toString(); + } +} diff --git a/UIPlugin/src/main/java/me/egg82/fuiplugin/UIPlugin.java b/UIPlugin/src/main/java/me/egg82/fuiplugin/UIPlugin.java new file mode 100644 index 0000000..c8a9296 --- /dev/null +++ b/UIPlugin/src/main/java/me/egg82/fuiplugin/UIPlugin.java @@ -0,0 +1,117 @@ +package me.egg82.fuiplugin; + +import com.sasorio.event.EventConfig; +import me.egg82.fetcharr.api.FetcharrAPIProvider; +import me.egg82.fetcharr.api.event.FetcharrEvent; +import me.egg82.fetcharr.api.plugin.Plugin; +import me.egg82.fetcharr.api.plugin.PluginContext; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.spongepowered.configurate.CommentedConfigurationNode; +import org.spongepowered.configurate.yaml.YamlConfigurationLoader; + +import java.io.*; + +public class UIPlugin implements Plugin { + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private volatile boolean started = false; + private UIServer server; + private EventLog eventLog; + + public UIPlugin() { + logger.info("UI plugin ready to init"); + } + + @Override + public void init(@NotNull PluginContext context) throws Exception { + CommentedConfigurationNode config = loadConfig(new File(context.configDir(), "config.yaml")); + int port = config != null ? config.node("port").getInt(8080) : 8080; + int maxEvents = config != null ? config.node("max-events").getInt(500) : 500; + + eventLog = new EventLog(maxEvents); + server = new UIServer(port, eventLog); + + FetcharrAPIProvider.instance().events().subscribe( + FetcharrEvent.class, + EventConfig.of(Integer.MAX_VALUE, false, false), + this::onEvent + ); + + logger.info("UI plugin initialized, will serve on port {}", port); + } + + @Override + public void start() throws Exception { + server.start(); + started = true; + logger.info("UI plugin started"); + } + + @Override + public void stop() throws Exception { + started = false; + if (server != null) { + server.stop(); + } + logger.info("UI plugin stopped"); + } + + private void onEvent(@NotNull FetcharrEvent event) { + if (!started) return; + eventLog.add(EventEntry.from(event)); + server.broadcast(eventLog.toJson()); + } + + private @Nullable CommentedConfigurationNode loadConfig(@NotNull File configFile) { + if (!configFile.exists() || !configFile.isFile() || configFile.length() == 0L) { + try (InputStream resource = getClass().getResourceAsStream("/config.yaml"); + FileWriter out = new FileWriter(configFile); + BufferedWriter writer = new BufferedWriter(out)) { + + if (resource == null) { + logger.error("Could not get resource /config.yaml"); + return null; + } + + File parent = configFile.getParentFile(); + if (parent.exists() && !parent.isDirectory()) { + if (!parent.delete()) { + logger.error("Could not delete file {}", parent.getAbsolutePath()); + return null; + } + } + if (!parent.exists() && !parent.mkdirs()) { + logger.error("Could not create directory {}", parent.getAbsolutePath()); + return null; + } + + byte[] buffer = new byte[250]; + int len; + while ((len = resource.read(buffer)) > 0) { + char[] chars = new char[len]; + for (int i = 0; i < len; i++) { + chars[i] = (char) buffer[i]; + } + writer.write(chars); + } + } catch (IOException ex) { + logger.error("Could not write default config to {}", configFile.getAbsolutePath(), ex); + return null; + } + } + + try (FileReader file = new FileReader(configFile); + BufferedReader reader = new BufferedReader(file)) { + return YamlConfigurationLoader.builder() + .source(() -> reader) + .build() + .load(); + } catch (IOException ex) { + logger.error("Could not read config file at {}", configFile.getAbsolutePath(), ex); + return null; + } + } +} diff --git a/UIPlugin/src/main/java/me/egg82/fuiplugin/UIServer.java b/UIPlugin/src/main/java/me/egg82/fuiplugin/UIServer.java new file mode 100644 index 0000000..ad31fea --- /dev/null +++ b/UIPlugin/src/main/java/me/egg82/fuiplugin/UIServer.java @@ -0,0 +1,261 @@ +package me.egg82.fuiplugin; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import me.egg82.fetcharr.api.FetcharrAPIProvider; +import me.egg82.fetcharr.api.model.plugin.EnabledPlugin; +import me.egg82.fetcharr.api.model.update.Updater; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.*; + +public class UIServer { + private static final Logger logger = LoggerFactory.getLogger(UIServer.class); + + private final int port; + private final EventLog eventLog; + private final Set> sseClients = ConcurrentHashMap.newKeySet(); + + private HttpServer httpServer; + + public UIServer(int port, @NotNull EventLog eventLog) { + this.port = port; + this.eventLog = eventLog; + } + + public void start() throws IOException { + httpServer = HttpServer.create(new InetSocketAddress(port), 0); + httpServer.setExecutor(Executors.newCachedThreadPool()); + + httpServer.createContext("/api/status", this::handleStatus); + httpServer.createContext("/api/events", this::handleEvents); + httpServer.createContext("/api/stream", this::handleStream); + httpServer.createContext("/", this::handleStatic); + + httpServer.start(); + logger.info("Fetcharr UI available at http://localhost:{}/", port); + } + + public void stop() { + if (httpServer != null) { + // Unblock all SSE clients + for (BlockingQueue queue : sseClients) { + queue.offer("__CLOSE__"); + } + httpServer.stop(1); + } + } + + /** Push a JSON payload to all connected SSE clients. */ + public void broadcast(@NotNull String eventsJson) { + String message = "event: events\ndata: " + eventsJson + "\n\n"; + for (BlockingQueue queue : sseClients) { + queue.offer(message); + } + } + + // ------------------------------------------------------------------------- + // Handlers + // ------------------------------------------------------------------------- + + private void handleStatus(@NotNull HttpExchange exchange) throws IOException { + if (!exchange.getRequestMethod().equalsIgnoreCase("GET")) { + exchange.sendResponseHeaders(405, -1); + return; + } + addCorsHeaders(exchange); + String json = buildStatusJson(); + sendJson(exchange, 200, json); + } + + private void handleEvents(@NotNull HttpExchange exchange) throws IOException { + if (!exchange.getRequestMethod().equalsIgnoreCase("GET")) { + exchange.sendResponseHeaders(405, -1); + return; + } + addCorsHeaders(exchange); + sendJson(exchange, 200, eventLog.toJson()); + } + + private void handleStream(@NotNull HttpExchange exchange) throws IOException { + if (!exchange.getRequestMethod().equalsIgnoreCase("GET")) { + exchange.sendResponseHeaders(405, -1); + return; + } + exchange.getResponseHeaders().set("Content-Type", "text/event-stream; charset=utf-8"); + exchange.getResponseHeaders().set("Cache-Control", "no-cache"); + exchange.getResponseHeaders().set("Connection", "keep-alive"); + exchange.getResponseHeaders().set("Access-Control-Allow-Origin", "*"); + exchange.sendResponseHeaders(200, 0); + + BlockingQueue queue = new LinkedBlockingQueue<>(); + sseClients.add(queue); + + try (OutputStream out = exchange.getResponseBody()) { + // Send initial data immediately + String initial = "event: events\ndata: " + eventLog.toJson() + "\n\n"; + out.write(initial.getBytes(StandardCharsets.UTF_8)); + out.flush(); + + while (true) { + String message; + try { + message = queue.poll(25, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + + if (message == null) { + // Send a heartbeat comment to keep the connection alive + out.write(": heartbeat\n\n".getBytes(StandardCharsets.UTF_8)); + } else if (message.equals("__CLOSE__")) { + break; + } else { + out.write(message.getBytes(StandardCharsets.UTF_8)); + } + out.flush(); + } + } catch (IOException ignored) { + // Client disconnected + } finally { + sseClients.remove(queue); + } + } + + private void handleStatic(@NotNull HttpExchange exchange) throws IOException { + if (!exchange.getRequestMethod().equalsIgnoreCase("GET")) { + exchange.sendResponseHeaders(405, -1); + return; + } + + String path = exchange.getRequestURI().getPath(); + if (path.equals("/") || path.isEmpty()) { + path = "/web/index.html"; + } else { + path = "/web" + path; + } + + byte[] content = loadResource(path); + if (content == null) { + byte[] notFound = "404 Not Found".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(404, notFound.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(notFound); + } + return; + } + + String contentType = guessContentType(path); + exchange.getResponseHeaders().set("Content-Type", contentType); + exchange.sendResponseHeaders(200, content.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(content); + } + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private @Nullable byte[] loadResource(@NotNull String path) { + try (InputStream is = getClass().getResourceAsStream(path)) { + if (is == null) return null; + return is.readAllBytes(); + } catch (IOException e) { + return null; + } + } + + private @NotNull String guessContentType(@NotNull String path) { + if (path.endsWith(".html")) return "text/html; charset=utf-8"; + if (path.endsWith(".css")) return "text/css; charset=utf-8"; + if (path.endsWith(".js")) return "application/javascript; charset=utf-8"; + return "application/octet-stream"; + } + + private void sendJson(@NotNull HttpExchange exchange, int status, @NotNull String json) throws IOException { + byte[] bytes = json.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(bytes); + } + } + + private void addCorsHeaders(@NotNull HttpExchange exchange) { + exchange.getResponseHeaders().set("Access-Control-Allow-Origin", "*"); + } + + private @NotNull String buildStatusJson() { + var api = FetcharrAPIProvider.instance(); + boolean dryRun = api.updateManager().dryRun(); + + StringBuilder sb = new StringBuilder("{"); + sb.append("\"dryRun\":").append(dryRun).append(","); + + // Updaters + sb.append("\"updaters\":["); + var updaters = api.updateManager().updaters(); + for (int i = 0; i < updaters.size(); i++) { + if (i > 0) sb.append(","); + appendUpdaterJson(sb, updaters.get(i)); + } + sb.append("],"); + + // Plugins + sb.append("\"plugins\":["); + var plugins = api.pluginManager().plugins(); + int pi = 0; + for (EnabledPlugin plugin : plugins) { + if (pi++ > 0) sb.append(","); + appendPluginJson(sb, plugin); + } + sb.append("]"); + + sb.append("}"); + return sb.toString(); + } + + private void appendUpdaterJson(@NotNull StringBuilder sb, @NotNull Updater u) { + var cfg = u.config(); + sb.append("{"); + sb.append("\"type\":\"").append(jsonEscape(cfg.type().name())).append("\","); + sb.append("\"url\":\"").append(jsonEscape(cfg.url())).append("\","); + sb.append("\"id\":").append(cfg.id()).append(","); + sb.append("\"searchAmount\":").append(cfg.searchAmount()).append(","); + sb.append("\"searchInterval\":\"").append(jsonEscape(cfg.searchInterval().toString())).append("\","); + sb.append("\"monitoredOnly\":").append(cfg.monitoredOnly()).append(","); + sb.append("\"missingStatus\":\"").append(jsonEscape(cfg.missingStatus().name())).append("\","); + sb.append("\"useCutoff\":").append(cfg.useCutoff()); + sb.append("}"); + } + + private void appendPluginJson(@NotNull StringBuilder sb, @NotNull EnabledPlugin p) { + var desc = p.descriptor(); + sb.append("{"); + sb.append("\"id\":\"").append(jsonEscape(desc.id())).append("\","); + sb.append("\"name\":\"").append(jsonEscape(desc.name())).append("\","); + sb.append("\"version\":\"").append(jsonEscape(desc.version())).append("\","); + sb.append("\"description\":\"").append(jsonEscape(desc.description() != null ? desc.description() : "")).append("\""); + sb.append("}"); + } + + private static @NotNull String jsonEscape(@NotNull String s) { + return s.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } +} diff --git a/UIPlugin/src/main/resources/config.yaml b/UIPlugin/src/main/resources/config.yaml new file mode 100644 index 0000000..3f86218 --- /dev/null +++ b/UIPlugin/src/main/resources/config.yaml @@ -0,0 +1,5 @@ +# Port the web UI will listen on +port: 8080 + +# Maximum number of events to keep in the activity log +max-events: 500 diff --git a/UIPlugin/src/main/resources/plugin.yaml b/UIPlugin/src/main/resources/plugin.yaml new file mode 100644 index 0000000..84c7c31 --- /dev/null +++ b/UIPlugin/src/main/resources/plugin.yaml @@ -0,0 +1,6 @@ +id: ui +name: UI +version: ${ui.version} +description: Web UI plugin for Fetcharr +authors: [egg82] +class: me.egg82.fuiplugin.UIPlugin diff --git a/UIPlugin/src/main/resources/web/index.html b/UIPlugin/src/main/resources/web/index.html new file mode 100644 index 0000000..ee5d17b --- /dev/null +++ b/UIPlugin/src/main/resources/web/index.html @@ -0,0 +1,650 @@ + + + + + +Fetcharr + + + + +
+ + DRY RUN +
+
+ connecting… +
+
+ +
+ +
+
+ Updaters + 0 +
+
+
No updaters loaded
+
+
+ + +
+
+ Plugins + 0 +
+
+
No plugins loaded
+
+
+ + +
+
+ Activity + 0 +
+
+ + + + + + + +
+
+
Waiting for events…
+
+
+
+ + + + diff --git a/pom.xml b/pom.xml index bf626bc..2e2b254 100644 --- a/pom.xml +++ b/pom.xml @@ -20,6 +20,7 @@ 2.1.0 1.0.2 1.1.0 + 1.0.0 26.1.0 @@ -179,5 +180,6 @@ API App Webhook + UIPlugin \ No newline at end of file