commit 93e8401ed3fb06b7f2111af0d3f450dfc7f28728 Author: davidtio Date: Sat Feb 28 12:56:14 2026 +0800 Initial commit: Scala/Thorium task manager demo app diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..214ea0c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +# === Build stage === +FROM eclipse-temurin:17 AS build + +# Install sbt +RUN apt-get update && apt-get install -y curl gnupg && \ + echo "deb https://repo.scala-sbt.org/scalasbt/debian all main" > /etc/apt/sources.list.d/sbt.list && \ + curl -sL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x2EE0EA64E40A89B84B2DF73499E82A75642AC823" | apt-key add && \ + apt-get update && apt-get install -y sbt && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Cache dependency resolution +COPY build.sbt . +COPY project/build.properties project/ +COPY project/plugins.sbt project/ +RUN sbt update + +# Copy source and build fat JAR +COPY src/ src/ +RUN sbt assembly + +# === Runtime stage === +FROM eclipse-temurin:17-jre-alpine + +RUN addgroup -S app && adduser -S app -G app +WORKDIR /app + +COPY --from=build /app/target/scala-3.7.1/demoapp.jar ./demoapp.jar + +RUN chown -R app:app /app +USER app + +EXPOSE 8080 + +ENV PORT=8080 +ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+UseSerialGC" + +ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar demoapp.jar"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..f14d1b8 --- /dev/null +++ b/README.md @@ -0,0 +1,110 @@ +# demoapp — Clouderized Demo App + +A task manager built with Scala 3 + Thorium, used to demo the full Clouderized deployment cycle: edit config, push, rebuild, see changes live. + +## Prerequisites + +- Access to `git.clouderized.com` (Gitea) +- The app deployed at `demoapp.clouderized.com` + +## Running the Demo + +### 1. Show the live app + +Open `https://demoapp.clouderized.com` in a browser. Point out: + +- The **teal header** with app name and version (v1.0.0) +- CRUD functionality — add, complete, and delete tasks +- The `/health` endpoint returning `{"status":"ok"}` + +### 2. Make a visible change + +Edit `src/main/resources/site.conf`: + +```diff + app { +- name = "DemoCust Tasks" +- version = "1.0.0" ++ name = "Clouderized Tasks" ++ version = "1.1.0" + theme { +- background = "#0F766E" ++ background = "#1E40AF" + font = "DM Sans" +- fontSize = "16px" ++ fontSize = "18px" + } + } +``` + +This changes the header from **teal to blue**, bumps the version, and increases font size. + +### 3. Push the change + +```bash +git add src/main/resources/site.conf +git commit -m "Update theme to blue, bump to v1.1.0" +git push +``` + +### 4. Watch the rebuild + +Go to Gitea Actions at `git.clouderized.com/cldrzd/demoapp/actions` and watch the pipeline: + +1. `sbt assembly` builds the fat JAR +2. Docker builds the container image +3. The old container is replaced with the new one + +### 5. See the result + +Refresh the browser. The page now shows: + +- **Blue header** instead of teal +- Version **v1.1.0** in the top-right +- Larger font size + +### 6. Reset for the next demo + +```bash +git revert HEAD --no-edit +git push +``` + +The app rebuilds and returns to its original teal theme at v1.0.0. + +## Config Reference + +All visual changes are driven by `src/main/resources/site.conf`: + +| Key | Default | What it controls | +|-----|---------|-----------------| +| `app.name` | `"DemoCust Tasks"` | Header title | +| `app.version` | `"1.0.0"` | Version badge in header | +| `app.theme.background` | `"#0F766E"` (teal) | Header and button color | +| `app.theme.font` | `"DM Sans"` | Body font family | +| `app.theme.fontSize` | `"16px"` | Base font size | + +## Suggested color swaps for demos + +| Color | Hex | Effect | +|-------|-----|--------| +| Teal (default) | `#0F766E` | Clouderized brand color | +| Blue | `#1E40AF` | Obvious visual change | +| Purple | `#7C3AED` | Another clear contrast | +| Red | `#DC2626` | High-contrast demo | + +## Endpoints + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/` | Task list (HTML) | +| GET | `/health` | Health check (JSON) | +| POST | `/tasks` | Create task (form) | +| POST | `/tasks/:id/toggle` | Toggle completion | +| POST | `/tasks/:id/delete` | Delete task | + +## Notes + +- Task storage is **in-memory** — it resets on every redeploy, which keeps the demo clean. +- The app runs on the **Pro tier** (2GB RAM, 2 vCPU) since JVM needs the headroom. +- Three sample tasks are pre-seeded on startup. diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..34b4fc1 --- /dev/null +++ b/build.sbt @@ -0,0 +1,28 @@ +val scala3Version = "3.7.1" +val thoriumVersion = "0.11.39" + +lazy val root = project + .in(file(".")) + .settings( + name := "demoapp", + version := "0.1.0", + scalaVersion := scala3Version, + libraryDependencies ++= Seq( + "io.github.nicoburniske" %% "thorium" % thoriumVersion, + "com.typesafe" % "config" % "1.4.3", + "ch.qos.logback" % "logback-classic" % "1.5.6" + ), + assembly / mainClass := Some("Main"), + assembly / assemblyJarName := "demoapp.jar", + assembly / assemblyMergeStrategy := { + case PathList("META-INF", xs @ _*) => + xs match { + case "MANIFEST.MF" :: Nil => MergeStrategy.discard + case "services" :: _ => MergeStrategy.concat + case _ => MergeStrategy.discard + } + case "reference.conf" => MergeStrategy.concat + case x if x.endsWith(".conf") => MergeStrategy.concat + case _ => MergeStrategy.first + } + ) diff --git a/clouderized.yaml b/clouderized.yaml new file mode 100644 index 0000000..ef4301d --- /dev/null +++ b/clouderized.yaml @@ -0,0 +1,5 @@ +name: demoapp +tier: pro +port: 8080 +healthcheck: /health +jvm_opts: "-XX:MaxRAMPercentage=75.0 -XX:+UseSerialGC" diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..73df629 --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.10.7 diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 0000000..11fa359 --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1 @@ +addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.3.0") diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..6655414 --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,10 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + diff --git a/src/main/resources/site.conf b/src/main/resources/site.conf new file mode 100644 index 0000000..7675c00 --- /dev/null +++ b/src/main/resources/site.conf @@ -0,0 +1,9 @@ +app { + name = "DemoCust Tasks" + version = "1.0.0" + theme { + background = "#0F766E" + font = "DM Sans" + fontSize = "16px" + } +} diff --git a/src/main/scala/Main.scala b/src/main/scala/Main.scala new file mode 100644 index 0000000..c21bdfd --- /dev/null +++ b/src/main/scala/Main.scala @@ -0,0 +1,162 @@ +import com.sun.net.httpserver.{HttpExchange, HttpServer} +import java.net.InetSocketAddress +import java.net.URLDecoder +import java.nio.charset.StandardCharsets + +object Main: + + def main(args: Array[String]): Unit = + val port = sys.env.getOrElse("PORT", "8080").toInt + val server = HttpServer.create(InetSocketAddress(port), 0) + + server.createContext("/health", exchange => { + val response = """{"status":"ok"}""" + exchange.getResponseHeaders.set("Content-Type", "application/json") + exchange.sendResponseHeaders(200, response.getBytes.length) + val os = exchange.getResponseBody + os.write(response.getBytes) + os.close() + }) + + server.createContext("/tasks", exchange => { + val method = exchange.getRequestMethod + val path = exchange.getRequestURI.getPath + + (method, path) match + case ("POST", "/tasks") => + val body = String(exchange.getRequestBody.readAllBytes(), StandardCharsets.UTF_8) + val title = parseFormField(body, "title") + TaskController.createTask(title) + redirect(exchange, "/") + + case ("POST", p) if p.endsWith("/toggle") => + val id = p.stripPrefix("/tasks/").stripSuffix("/toggle") + TaskController.toggleTask(id) + redirect(exchange, "/") + + case ("POST", p) if p.endsWith("/delete") => + val id = p.stripPrefix("/tasks/").stripSuffix("/delete") + TaskController.deleteTask(id) + redirect(exchange, "/") + + case _ => + exchange.sendResponseHeaders(404, -1) + exchange.close() + }) + + server.createContext("/", exchange => { + val path = exchange.getRequestURI.getPath + if path == "/" || path.isEmpty then + val html = renderPage() + exchange.getResponseHeaders.set("Content-Type", "text/html; charset=utf-8") + exchange.sendResponseHeaders(200, html.getBytes(StandardCharsets.UTF_8).length) + val os = exchange.getResponseBody + os.write(html.getBytes(StandardCharsets.UTF_8)) + os.close() + else + exchange.sendResponseHeaders(404, -1) + exchange.close() + }) + + server.setExecutor(null) + server.start() + println(s"demoapp started on port $port") + println(s" app: ${SiteConfig.appName} v${SiteConfig.appVersion}") + + private def redirect(exchange: HttpExchange, location: String): Unit = + exchange.getResponseHeaders.set("Location", location) + exchange.sendResponseHeaders(303, -1) + exchange.close() + + private def parseFormField(body: String, field: String): String = + body.split("&").map(_.split("=", 2)).collectFirst { + case Array(k, v) if k == field => + URLDecoder.decode(v, StandardCharsets.UTF_8) + }.getOrElse("") + + private def esc(s: String): String = + s.replace("&", "&").replace("<", "<").replace(">", ">") + .replace("\"", """).replace("'", "'") + + private def renderPage(): String = + val cfg = SiteConfig + val tasks = TaskStore.all() + val totalCount = tasks.size + val doneCount = tasks.count(_.completed) + + val taskRows = tasks.map { t => + val checkedClass = if t.completed then "line-through text-gray-400" else "" + val checkIcon = if t.completed then "✓" else "" + s""" +
+
+ +
+ ${esc(t.title)} +
+ +
+
""" + }.mkString("\n") + + s""" + + + + + ${esc(cfg.appName)} + + + + + +
+
+

+ ${esc(cfg.appName)} +

+ v${esc(cfg.appVersion)} +
+
+ +
+
+
+

Tasks

+ $doneCount / $totalCount completed +
+ +
+ $taskRows + ${if tasks.isEmpty then """

No tasks yet. Add one below!

""" else ""} +
+
+ +
+

Add Task

+
+ + +
+
+ + +
+ +""" diff --git a/src/main/scala/SiteConfig.scala b/src/main/scala/SiteConfig.scala new file mode 100644 index 0000000..3b0547d --- /dev/null +++ b/src/main/scala/SiteConfig.scala @@ -0,0 +1,10 @@ +import com.typesafe.config.ConfigFactory + +object SiteConfig: + private val config = ConfigFactory.load("site") + + val appName: String = config.getString("app.name") + val appVersion: String = config.getString("app.version") + val bgColor: String = config.getString("app.theme.background") + val font: String = config.getString("app.theme.font") + val fontSize: String = config.getString("app.theme.fontSize") diff --git a/src/main/scala/TaskController.scala b/src/main/scala/TaskController.scala new file mode 100644 index 0000000..8f20ebd --- /dev/null +++ b/src/main/scala/TaskController.scala @@ -0,0 +1,14 @@ +import java.util.UUID + +object TaskController: + + def createTask(title: String): Unit = + if title.trim.nonEmpty then TaskStore.add(title) + + def toggleTask(id: String): Unit = + try TaskStore.toggle(UUID.fromString(id)) + catch case _: IllegalArgumentException => () + + def deleteTask(id: String): Unit = + try TaskStore.delete(UUID.fromString(id)) + catch case _: IllegalArgumentException => () diff --git a/src/main/scala/TaskStore.scala b/src/main/scala/TaskStore.scala new file mode 100644 index 0000000..0732ee0 --- /dev/null +++ b/src/main/scala/TaskStore.scala @@ -0,0 +1,41 @@ +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import scala.jdk.CollectionConverters.* + +case class Task(id: UUID, title: String, completed: Boolean) + +object TaskStore: + private val tasks = ConcurrentHashMap[UUID, Task]() + + // Pre-seed sample tasks + locally { + val samples = List( + "Deploy Scala app to Clouderized", + "Set up CI/CD with Gitea Actions", + "Configure custom domain" + ) + samples.foreach { title => + val id = UUID.randomUUID() + tasks.put(id, Task(id, title, false)) + } + } + + def all(): List[Task] = + tasks.values().asScala.toList.sortBy(_.title) + + def add(title: String): Task = + val id = UUID.randomUUID() + val task = Task(id, title.trim, false) + tasks.put(id, task) + task + + def toggle(id: UUID): Unit = + Option(tasks.get(id)).foreach { task => + tasks.put(id, task.copy(completed = !task.completed)) + } + + def delete(id: UUID): Unit = + tasks.remove(id) + + def get(id: UUID): Option[Task] = + Option(tasks.get(id))