Compare commits

..

5 commits

Author SHA1 Message Date
mikes222
5a8c072d64 A little refreshing ui change :-) 2025-08-16 18:58:17 +02:00
7631cfd326 server: docker: remove debug container from compose file
Signed-off-by: Max R. Carrara <max@aequito.sh>
2025-08-16 18:48:34 +02:00
f87843785a server: make/docker: add deployment procedures
Signed-off-by: Max R. Carrara <max@aequito.sh>
2025-08-16 18:48:34 +02:00
6786def063 server: dockerfile: fix broken ENTRYPOINT from generated project
This cost me an entire afternoon. I don't know whose bright idea it
was to just slap all arguments into the ENTRYPOINT, but here we are.

ENTRYPOINT now just runs the `server` executable, while COMMAND is
used to pass arguments / flags to it. This is useful for e.g. passing
`--apply-migrations` for deployments.

Signed-off-by: Max R. Carrara <max@aequito.sh>
2025-08-16 18:48:34 +02:00
5b8b317e89 server: dockerfile: add todos for improvements
Signed-off-by: Max R. Carrara <max@aequito.sh>
2025-08-16 18:48:34 +02:00
14 changed files with 194 additions and 30 deletions

View file

@ -13,3 +13,15 @@ A great starting point for learning Serverpod is our documentation site at:
To run the project, first make sure that the server is running, then do:
flutter run
## Flutter start:
add environment variable in the Additional arguments field in Android Studio:
--dart-define=SERVER_URL=http://localhost:5432/
Note: Host MUST end with a slash
## docker start:
wien_talks_server>docker compose -f docker-compose.local.yaml up -d

View file

@ -10,6 +10,8 @@ class FunmapMgr {
late Client client;
late final serverUrl;
factory FunmapMgr() {
if (_instance != null) return _instance!;
_instance = FunmapMgr._();
@ -24,11 +26,9 @@ class FunmapMgr {
// E.g. `flutter run --dart-define=SERVER_URL=https://api.example.com/`
const serverUrlFromEnv = String.fromEnvironment('SERVER_URL');
final serverUrl =
serverUrlFromEnv.isEmpty ? 'http://$localhost:8080/' : serverUrlFromEnv;
serverUrl = serverUrlFromEnv.isEmpty ? 'http://$localhost:8080/' : serverUrlFromEnv;
client = Client(serverUrl, connectionTimeout: const Duration(seconds: 5))
..connectivityMonitor = FlutterConnectivityMonitor();
client = Client(serverUrl, connectionTimeout: const Duration(seconds: 5))..connectivityMonitor = FlutterConnectivityMonitor();
client.openStreamingConnection();
}

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:wien_talks_flutter/helper/funmap_mgr.dart';
import 'package:wien_talks_flutter/show_latest_news_widget.dart';
import 'package:wien_talks_flutter/widgets/intro_text_widget.dart';
import 'package:wien_talks_flutter/widgets/screen_widget.dart';
@ -19,7 +20,7 @@ class HomeScreen extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IntroTextWidget(),
ShowLatestNewsWidget(),
SizedBox(height: 200, child: ShowLatestNewsWidget()),
SizedBox(
height: 30,
),
@ -27,6 +28,9 @@ class HomeScreen extends StatelessWidget {
children: [
Expanded(
child: ElevatedButton(
style: ButtonStyle(
backgroundColor: WidgetStateProperty.all(Theme.of(context).primaryColor),
foregroundColor: WidgetStateProperty.all(Theme.of(context).colorScheme.onPrimary)),
onPressed: () {
context.pushNamed("create_event");
},
@ -38,6 +42,12 @@ class HomeScreen extends StatelessWidget {
height: 30,
),
CarouselWidget(),
Row(
children: [
Spacer(),
Text(FunmapMgr().serverUrl, style: Theme.of(context).textTheme.bodySmall),
],
)
],
),
),

View file

@ -26,29 +26,24 @@ class LocationMgr {
ViewModel? viewModel;
late MapModel mapModel;
MapModel? mapModel;
IconMarker? iconMarker;
final DisplayModel displayModel = DisplayModel(maxZoomLevel: 20);
final DisplayModel displayModel = DisplayModel(maxZoomLevel: 18);
final SymbolCache symbolCache = FileSymbolCache();
final JobRenderer jobRenderer = MapOnlineRenderer();
final MarkerByItemDataStore markerDataStore = MarkerByItemDataStore();
factory LocationMgr() {
_instance ??= LocationMgr._();
return _instance!;
}
LocationMgr._() {
mapModel = MapModel(
displayModel: displayModel,
renderer: jobRenderer,
symbolCache: symbolCache,
tileBitmapCache: bitmapCache,
);
}
LocationMgr._() {}
Future<String?> startup() async {
serviceEnabled = await location.serviceEnabled();
@ -66,6 +61,13 @@ class LocationMgr {
return "No permissions granted";
}
}
mapModel = MapModel(
displayModel: displayModel,
renderer: jobRenderer,
symbolCache: symbolCache,
tileBitmapCache: bitmapCache,
);
mapModel?.markerDataStores.add(markerDataStore);
viewModel = ViewModel(displayModel: displayModel);
_subscription = location.onLocationChanged.listen((LocationData currentLocation) {
_lastLocationData = currentLocation;
@ -78,7 +80,7 @@ class LocationMgr {
color: Colors.red,
center: LatLong(currentLocation.latitude!, currentLocation.longitude!),
displayModel: displayModel);
mapModel.markerDataStores.add(MarkerDataStore()..addMarker(iconMarker!));
mapModel?.markerDataStores.add(MarkerDataStore()..addMarker(iconMarker!));
}
}
_subject.add(currentLocation);
@ -89,7 +91,8 @@ class LocationMgr {
void shutdown() {
_subscription?.cancel();
_subscription = null;
mapModel.markerDataStores.clear();
mapModel?.dispose();
mapModel = null;
iconMarker = null;
viewModel?.dispose();
viewModel = null;

View file

@ -1,9 +1,7 @@
import 'package:flutter/material.dart';
import 'package:wien_talks_flutter/helper/funmap_mgr.dart';
import 'package:wien_talks_flutter/helper/go_router.dart';
void main() {
FunmapMgr().configure();
runApp(const MyApp());
}

View file

@ -17,7 +17,7 @@ class _MapfileWidgetState extends State<MapfileWidget> {
return MapviewWidget(
displayModel: LocationMgr().displayModel,
createMapModel: () async {
return LocationMgr().mapModel;
return LocationMgr().mapModel!;
},
createViewModel: () async {
return LocationMgr().viewModel!;

View file

@ -78,6 +78,9 @@ class _NewsInputFormState extends State<NewsInputForm> {
),
const SizedBox(height: 16.0),
ElevatedButton(
style: ButtonStyle(
backgroundColor: WidgetStateProperty.all(Theme.of(context).primaryColor),
foregroundColor: WidgetStateProperty.all(Theme.of(context).colorScheme.onPrimary)),
onPressed: _submitForm,
child: const Text('Submit News'),
),

View file

@ -3,7 +3,11 @@ import 'package:flutter/material.dart';
class ErrorSnackbar {
void show(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(message, style: TextStyle(color: Theme.of(context).colorScheme.onError)),
content: Text(
message,
style: TextStyle(color: Theme.of(context).colorScheme.onError),
maxLines: 3,
),
showCloseIcon: true,
duration: Duration(seconds: 30),
backgroundColor: Theme.of(context).colorScheme.error,

View file

@ -6,9 +6,9 @@ class IntroTextWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Card(
return Card(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -23,7 +23,7 @@ class IntroTextWidget extends StatelessWidget {
const SizedBox(height: 20),
Text(
"Ever experienced something funny, weird, or just too good not to share? "
"With FunMap, you can pin your funniest moments and strange encounters right on the map! 🗺️😂",
"With FunMap, you can pin your funniest moments and strange encounters right on the map! 😂",
style: GoogleFonts.roboto(fontSize: 16, height: 1.5),
),
const SizedBox(height: 16),
@ -38,16 +38,16 @@ class IntroTextWidget extends StatelessWidget {
"👉 Add your event, mark the spot, and let the community enjoy the laughter with you.",
style: GoogleFonts.roboto(
fontSize: 16,
fontWeight: FontWeight.w600,
fontStyle: FontStyle.italic,
height: 1.5,
),
),
const SizedBox(height: 16),
Text(
"Because the worlds a lot more fun when we laugh together. 🌍✨",
"Because the worlds a lot more fun when we laugh together.",
style: GoogleFonts.roboto(
fontSize: 16,
fontStyle: FontStyle.italic,
fontWeight: FontWeight.w600,
height: 1.5,
),
),

View file

@ -1,6 +1,7 @@
# Build stage
FROM dart:3.5.0 AS build
WORKDIR /app
# TODO: more fine-grained building
COPY . .
# Install dependencies and compile the server executable
@ -23,6 +24,7 @@ COPY --from=build /runtime/ /
COPY --from=build /app/bin/server server
# Copy configuration files and resources
# TODO: don't copy entire config dir, only what's needed
COPY --from=build /app/config/ config/
COPY --from=build /app/web/ web/
COPY --from=build /app/migrations/ migrations/
@ -35,5 +37,5 @@ EXPOSE 8080
EXPOSE 8081
EXPOSE 8082
# Define the entrypoint command
ENTRYPOINT ./server --mode=$runmode --server-id=$serverid --logging=$logging --role=$role
ENTRYPOINT ["/server"]
CMD ["--mode", "production", "--server-id", "default", "--logging", "normal", "--role", "monolith"]

View file

@ -3,9 +3,20 @@ include ../defines.mk
COMPOSE_FILE_LOCAL = docker-compose.local.yaml
COMPOSE_FILE_DEPLOY = docker-compose.deploy.yaml
# NOTE: the --env-file flags are necessary because the env_file directive
# in the docker-compose.yaml doesn't work for env vars that are used inside the
# compose file itself.
# This is jank, but it is what it is <.<
COMPOSE_COMMON_ARGS_DEPLOY = -f $(COMPOSE_FILE_DEPLOY) \
--env-file env.d/postgres.env \
--env-file env.d/server.env \
# Basically the current directory's name, so wien_talks_server
COMPOSE_PROJECT := $(shell basename $(shell pwd))
DEPLOY_NETWORK = docker-net
.env: .env.template
cp -a .env.template .env
@ -25,3 +36,33 @@ local-clean: local-down
for VOLUME in $(shell docker compose -f $(COMPOSE_FILE_LOCAL) volumes -q); \
do docker volume rm "$$VOLUME"; done
.PHONY: deploy deploy-env deploy-build deploy-stop deploy-down
deploy:
if test -z "$$(docker network ls -q --filter name=$(DEPLOY_NETWORK))"; then \
docker network create --driver bridge $(DEPLOY_NETWORK); fi
docker compose $(COMPOSE_COMMON_ARGS_DEPLOY) up -d
# TODO: parameterize .env files
deploy-env: env.d/postgres.env.template env.d/server.env.template
if test -e env.d/postgres.env; then echo "env.d/postgres.env already exists"; exit 1; fi
if test -e env.d/server.env; then echo "env.d/server.env already exists"; exit 1; fi
cp -a env.d/postgres.env.template env.d/postgres.env
cp -a env.d/server.env.template env.d/server.env
@echo -e "\n!!! Environment files for deployment initialized !!!\n\nDon't forget to edit them!"
deploy-build:
docker compose $(COMPOSE_COMMON_ARGS_DEPLOY) build --no-cache
deploy-stop:
if test -n "$$(docker network ls -q --filter name=$(DEPLOY_NETWORK))"; then \
docker compose $(COMPOSE_COMMON_ARGS_DEPLOY) stop; fi
deploy-down:
if test -n "$$(docker network ls -q --filter name=$(DEPLOY_NETWORK))"; then \
docker compose $(COMPOSE_COMMON_ARGS_DEPLOY) down; fi
# Note: Doesn't clean up DB for safety reasons!
deploy-clean: deploy-down
if test -n "$$(docker network ls -q --filter name=$(DEPLOY_NETWORK))"; then \
docker network rm $(DEPLOY_NETWORK) > /dev/null; fi

View file

@ -0,0 +1,68 @@
networks:
docker-net:
name: docker-net
external: true
backend:
name: backend
driver: bridge
external: false
services:
postgres:
image: postgres:16-trixie
container_name: postgres
env_file:
- path: env.d/postgres.env
required: true
volumes:
- db:/var/lib/postgresql/data
restart: always
networks:
- backend
ports:
- "127.0.0.1:5432:5432"
server:
depends_on:
- postgres
build:
context: ./
image: wien-talks
command: [
"--mode",
"production",
"--server-id",
"default",
"--logging",
"normal",
"--role",
"monolith",
"--apply-migrations",
]
env_file:
- path: env.d/server.env
required: true
restart: always
networks:
- docker-net
- backend
labels:
- "traefik.enable=true"
- "traefik.http.routers.wien-talks-api.rule=Host(`${SERVERPOD_API_SERVER_PUBLIC_HOST}`)"
- "traefik.http.routers.wien-talks-api.entrypoints=secure"
- "traefik.http.routers.wien-talks-api.service=wien-talks-api-service"
- "traefik.http.services.wien-talks-api-service.loadbalancer.server.port=${SERVERPOD_API_SERVER_PORT}"
- "traefik.http.routers.wien-talks-insights.rule=Host(`${SERVERPOD_INSIGHTS_SERVER_PUBLIC_HOST}`)"
- "traefik.http.routers.wien-talks-insights.entrypoints=secure"
- "traefik.http.routers.wien-talks-insights.service=wien-talks-insights-service"
- "traefik.http.services.wien-talks-insights-service.loadbalancer.server.port=${SERVERPOD_INSIGHTS_SERVER_PORT}"
- "traefik.http.routers.wien-talks-web.rule=Host(`${SERVERPOD_WEB_SERVER_PUBLIC_HOST}`)"
- "traefik.http.routers.wien-talks-web.entrypoints=secure"
- "traefik.http.routers.wien-talks-web.service=wien-talks-web-service"
- "traefik.http.services.wien-talks-web-service.loadbalancer.server.port=${SERVERPOD_WEB_SERVER_PORT}"
volumes:
db:

View file

@ -0,0 +1,3 @@
POSTGRES_USER=postgres
POSTGRES_PASSWORD=sergtsop
POSTGRES_DB=wien_talks

View file

@ -0,0 +1,20 @@
SERVERPOD_API_SERVER_PORT=8080
SERVERPOD_API_SERVER_PUBLIC_HOST=localhost
SERVERPOD_API_SERVER_PUBLIC_PORT
SERVERPOD_API_SERVER_PUBLIC_SCHEME
SERVERPOD_DATABASE_HOST=postgres
SERVERPOD_DATABASE_IS_UNIX_SOCKET
SERVERPOD_DATABASE_NAME=wien_talks
SERVERPOD_DATABASE_PASSWORD=${POSTGRES_PASSWORD}
SERVERPOD_DATABASE_PORT=5432
SERVERPOD_DATABASE_REQUIRE_SSL=false
SERVERPOD_DATABASE_USER=postgres
SERVERPOD_INSIGHTS_SERVER_PORT=8081
SERVERPOD_INSIGHTS_SERVER_PUBLIC_HOST=localhost
SERVERPOD_INSIGHTS_SERVER_PUBLIC_SCHEME
SERVERPOD_MAX_REQUEST_SIZE
SERVERPOD_SERVICE_SECRET
SERVERPOD_WEB_SERVER_PORT=8082
SERVERPOD_WEB_SERVER_PUBLIC_HOST=localhost
SERVERPOD_WEB_SERVER_PUBLIC_PORT
SERVERPOD_WEB_SERVER_PUBLIC_SCHEME