Initial commit: Scala/Thorium task manager demo app
This commit is contained in:
39
Dockerfile
Normal file
39
Dockerfile
Normal file
@@ -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"]
|
||||||
110
README.md
Normal file
110
README.md
Normal file
@@ -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.
|
||||||
28
build.sbt
Normal file
28
build.sbt
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
)
|
||||||
5
clouderized.yaml
Normal file
5
clouderized.yaml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
name: demoapp
|
||||||
|
tier: pro
|
||||||
|
port: 8080
|
||||||
|
healthcheck: /health
|
||||||
|
jvm_opts: "-XX:MaxRAMPercentage=75.0 -XX:+UseSerialGC"
|
||||||
1
project/build.properties
Normal file
1
project/build.properties
Normal file
@@ -0,0 +1 @@
|
|||||||
|
sbt.version=1.10.7
|
||||||
1
project/plugins.sbt
Normal file
1
project/plugins.sbt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.3.0")
|
||||||
10
src/main/resources/logback.xml
Normal file
10
src/main/resources/logback.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<configuration>
|
||||||
|
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||||
|
<encoder>
|
||||||
|
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||||
|
</encoder>
|
||||||
|
</appender>
|
||||||
|
<root level="INFO">
|
||||||
|
<appender-ref ref="STDOUT" />
|
||||||
|
</root>
|
||||||
|
</configuration>
|
||||||
9
src/main/resources/site.conf
Normal file
9
src/main/resources/site.conf
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
app {
|
||||||
|
name = "DemoCust Tasks"
|
||||||
|
version = "1.0.0"
|
||||||
|
theme {
|
||||||
|
background = "#0F766E"
|
||||||
|
font = "DM Sans"
|
||||||
|
fontSize = "16px"
|
||||||
|
}
|
||||||
|
}
|
||||||
162
src/main/scala/Main.scala
Normal file
162
src/main/scala/Main.scala
Normal file
@@ -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"""
|
||||||
|
<div class="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 group transition-colors">
|
||||||
|
<form method="POST" action="/tasks/${t.id}/toggle" class="flex-none">
|
||||||
|
<button type="submit"
|
||||||
|
class="w-6 h-6 rounded-full border-2 ${if t.completed then "bg-emerald-500 border-emerald-500 text-white" else "border-gray-300 hover:border-teal-500"} flex items-center justify-center text-xs transition-colors">
|
||||||
|
$checkIcon
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<span class="flex-1 $checkedClass">${esc(t.title)}</span>
|
||||||
|
<form method="POST" action="/tasks/${t.id}/delete" class="flex-none opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button type="submit" class="text-red-400 hover:text-red-600 text-sm px-2 py-1 rounded hover:bg-red-50 transition-colors">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>"""
|
||||||
|
}.mkString("\n")
|
||||||
|
|
||||||
|
s"""<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>${esc(cfg.appName)}</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=DM+Sans:wght@400;500;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body { font-family: '${cfg.font}', sans-serif; font-size: ${cfg.fontSize}; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-50 min-h-screen">
|
||||||
|
<header style="background-color: ${cfg.bgColor};" class="shadow-lg">
|
||||||
|
<div class="max-w-2xl mx-auto px-6 py-5 flex items-center justify-between">
|
||||||
|
<h1 class="text-white text-2xl font-bold" style="font-family: 'Instrument Serif', serif;">
|
||||||
|
${esc(cfg.appName)}
|
||||||
|
</h1>
|
||||||
|
<span class="text-white/70 text-sm font-mono">v${esc(cfg.appVersion)}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="max-w-2xl mx-auto px-6 py-8">
|
||||||
|
<div class="bg-white rounded-xl shadow-md p-6 mb-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800">Tasks</h2>
|
||||||
|
<span class="text-sm text-gray-500">$doneCount / $totalCount completed</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
$taskRows
|
||||||
|
${if tasks.isEmpty then """<p class="text-gray-400 text-center py-8">No tasks yet. Add one below!</p>""" else ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-xl shadow-md p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 mb-4">Add Task</h2>
|
||||||
|
<form method="POST" action="/tasks" class="flex gap-3">
|
||||||
|
<input type="text" name="title" placeholder="What needs to be done?"
|
||||||
|
required
|
||||||
|
class="flex-1 border-2 border-gray-200 rounded-lg px-4 py-2 focus:border-teal-600 focus:ring-2 focus:ring-teal-600/10 outline-none transition-colors"
|
||||||
|
style="border-color: #E2E8F0;">
|
||||||
|
<button type="submit"
|
||||||
|
class="px-6 py-2 rounded-lg text-white font-medium hover:-translate-y-0.5 hover:shadow-lg transition-all duration-300"
|
||||||
|
style="background-color: ${cfg.bgColor};">
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="text-center text-gray-400 text-xs mt-8">
|
||||||
|
Powered by <span class="font-semibold">Clouderized</span> · Thorium/Scala
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
10
src/main/scala/SiteConfig.scala
Normal file
10
src/main/scala/SiteConfig.scala
Normal file
@@ -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")
|
||||||
14
src/main/scala/TaskController.scala
Normal file
14
src/main/scala/TaskController.scala
Normal file
@@ -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 => ()
|
||||||
41
src/main/scala/TaskStore.scala
Normal file
41
src/main/scala/TaskStore.scala
Normal file
@@ -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))
|
||||||
Reference in New Issue
Block a user