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: To run the project, first make sure that the server is running, then do:
flutter run 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 Client client;
late final serverUrl;
factory FunmapMgr() { factory FunmapMgr() {
if (_instance != null) return _instance!; if (_instance != null) return _instance!;
_instance = FunmapMgr._(); _instance = FunmapMgr._();
@ -24,11 +26,9 @@ class FunmapMgr {
// E.g. `flutter run --dart-define=SERVER_URL=https://api.example.com/` // E.g. `flutter run --dart-define=SERVER_URL=https://api.example.com/`
const serverUrlFromEnv = String.fromEnvironment('SERVER_URL'); const serverUrlFromEnv = String.fromEnvironment('SERVER_URL');
final serverUrl = serverUrl = serverUrlFromEnv.isEmpty ? 'http://$localhost:8080/' : serverUrlFromEnv;
serverUrlFromEnv.isEmpty ? 'http://$localhost:8080/' : serverUrlFromEnv;
client = Client(serverUrl, connectionTimeout: const Duration(seconds: 5)) client = Client(serverUrl, connectionTimeout: const Duration(seconds: 5))..connectivityMonitor = FlutterConnectivityMonitor();
..connectivityMonitor = FlutterConnectivityMonitor();
client.openStreamingConnection(); client.openStreamingConnection();
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,7 +3,11 @@ import 'package:flutter/material.dart';
class ErrorSnackbar { class ErrorSnackbar {
void show(BuildContext context, String message) { void show(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar( 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, showCloseIcon: true,
duration: Duration(seconds: 30), duration: Duration(seconds: 30),
backgroundColor: Theme.of(context).colorScheme.error, backgroundColor: Theme.of(context).colorScheme.error,

View file

@ -6,9 +6,9 @@ class IntroTextWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Card(
child: Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Card(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -23,7 +23,7 @@ class IntroTextWidget extends StatelessWidget {
const SizedBox(height: 20), const SizedBox(height: 20),
Text( Text(
"Ever experienced something funny, weird, or just too good not to share? " "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), style: GoogleFonts.roboto(fontSize: 16, height: 1.5),
), ),
const SizedBox(height: 16), 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.", "👉 Add your event, mark the spot, and let the community enjoy the laughter with you.",
style: GoogleFonts.roboto( style: GoogleFonts.roboto(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontStyle: FontStyle.italic,
height: 1.5, height: 1.5,
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( 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( style: GoogleFonts.roboto(
fontSize: 16, fontSize: 16,
fontStyle: FontStyle.italic, fontWeight: FontWeight.w600,
height: 1.5, height: 1.5,
), ),
), ),

View file

@ -1,6 +1,7 @@
# Build stage # Build stage
FROM dart:3.5.0 AS build FROM dart:3.5.0 AS build
WORKDIR /app WORKDIR /app
# TODO: more fine-grained building
COPY . . COPY . .
# Install dependencies and compile the server executable # Install dependencies and compile the server executable
@ -23,6 +24,7 @@ COPY --from=build /runtime/ /
COPY --from=build /app/bin/server server COPY --from=build /app/bin/server server
# Copy configuration files and resources # 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/config/ config/
COPY --from=build /app/web/ web/ COPY --from=build /app/web/ web/
COPY --from=build /app/migrations/ migrations/ COPY --from=build /app/migrations/ migrations/
@ -35,5 +37,5 @@ EXPOSE 8080
EXPOSE 8081 EXPOSE 8081
EXPOSE 8082 EXPOSE 8082
# Define the entrypoint command ENTRYPOINT ["/server"]
ENTRYPOINT ./server --mode=$runmode --server-id=$serverid --logging=$logging --role=$role 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_LOCAL = docker-compose.local.yaml
COMPOSE_FILE_DEPLOY = docker-compose.deploy.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 # Basically the current directory's name, so wien_talks_server
COMPOSE_PROJECT := $(shell basename $(shell pwd)) COMPOSE_PROJECT := $(shell basename $(shell pwd))
DEPLOY_NETWORK = docker-net
.env: .env.template .env: .env.template
cp -a .env.template .env 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); \ for VOLUME in $(shell docker compose -f $(COMPOSE_FILE_LOCAL) volumes -q); \
do docker volume rm "$$VOLUME"; done 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