Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45de2591a7 | ||
|
|
2fe6067085 | ||
|
|
ce2dfcd13c | ||
|
|
672f497733 |
@@ -22,14 +22,20 @@ This document contains instructions and documentation references for AI assistan
|
|||||||
* Related repos: , `core-api`, `tatlock`, `library-desk`, `scheduler`, `portainer-core`
|
* Related repos: , `core-api`, `tatlock`, `library-desk`, `scheduler`, `portainer-core`
|
||||||
|
|
||||||
### 🐳 Deployment & Infrastructure
|
### 🐳 Deployment & Infrastructure
|
||||||
|
|
||||||
|
**⚠️ IMPORTANT: Service Port Reference**
|
||||||
|
| Service | LAN Port | External URL | Notes |
|
||||||
|
|---------|----------|--------------|-------|
|
||||||
|
| **Core API** | 8083 | `api.schweitz.net` | FastAPI backend for this UI |
|
||||||
|
| **Tatlock API** | 8000 | `tatlock.schweitz.net` | Legacy Python API (Ollama proxy) |
|
||||||
|
| **Tatlock UI** | 9999 | `home.schweitz.net` | This Flutter app |
|
||||||
|
|
||||||
* **Full stack documentation**: Available in the `portainer-core` repo
|
* **Full stack documentation**: Available in the `portainer-core` repo
|
||||||
* Access: `curl http://localhost:3002/jpmschweitzer/portainer-core/raw/branch/main/CONTAINERS.md`
|
* Access: `curl http://192.168.86.149:3002/jpmschweitzer/portainer-core/raw/branch/main/CONTAINERS.md`
|
||||||
* Contains: All service ports, URLs, Redis DB allocations, external domains
|
* Contains: All service ports, URLs, Redis DB allocations, external domains
|
||||||
* **Tatlock deployment**:
|
* **Health checks**:
|
||||||
* LAN: `http://192.168.86.149:8000`
|
* Core API: `curl http://192.168.86.149:8083/health`
|
||||||
* External: `tatlock.schweitz.net` (behind Authentik SSO)
|
* Tatlock API: `curl http://192.168.86.149:8000/health`
|
||||||
* Redis DBs: 1 (memory), 6 (benchmarks)
|
|
||||||
* **Health check**: `curl http://192.168.86.149:8000/health`
|
|
||||||
|
|
||||||
### 🛡️ Git Discipline
|
### 🛡️ Git Discipline
|
||||||
* **Commit Messages:** Use the [Conventional Commits](https://www.conventionalcommits.org/) format.
|
* **Commit Messages:** Use the [Conventional Commits](https://www.conventionalcommits.org/) format.
|
||||||
|
|||||||
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.0.4] - 2026-01-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `health.json` generated at build time with app version info
|
||||||
|
- `health.html` now displays version, title, and status from health.json
|
||||||
|
|
||||||
|
## [1.0.3] - 2026-01-03
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed auth endpoint path: `/auth/me` → `/auth/users/me`
|
||||||
|
|
||||||
|
## [1.0.2] - 2026-01-03
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Production Docker build now uses correct API URLs
|
||||||
|
- Added `--dart-define` flags for `CORE_API_URL` and `TATLOCK_API_URL`
|
||||||
|
- This enables `requiresAuth=true` so authentication is actually triggered
|
||||||
|
- Updated AGENTS.md with clear service port reference table
|
||||||
|
|
||||||
|
## [1.0.1] - 2026-01-03
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Web authentication now works correctly with NPM forward auth
|
||||||
|
- Dio client sends cookies with requests via `withCredentials: true`
|
||||||
|
- Added platform-specific adapters (native vs web) for proper cookie handling
|
||||||
|
|
||||||
## [1.0.0] - 2026-01-03
|
## [1.0.0] - 2026-01-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+8
-2
@@ -15,8 +15,14 @@ COPY . .
|
|||||||
# Generate code with build_runner
|
# Generate code with build_runner
|
||||||
RUN dart run build_runner build --delete-conflicting-outputs
|
RUN dart run build_runner build --delete-conflicting-outputs
|
||||||
|
|
||||||
# Build for web release
|
# Generate health.json with version info
|
||||||
RUN flutter build web --release
|
RUN dart run tool/generate_health_json.dart
|
||||||
|
|
||||||
|
# Build for web release with production configuration
|
||||||
|
# These URLs enable authentication (requiresAuth = true when URL contains schweitz.net)
|
||||||
|
RUN flutter build web --release \
|
||||||
|
--dart-define=CORE_API_URL=https://api.schweitz.net \
|
||||||
|
--dart-define=TATLOCK_API_URL=https://tatlock.schweitz.net
|
||||||
|
|
||||||
# Stage 2: Serve with nginx
|
# Stage 2: Serve with nginx
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
|
|||||||
@@ -3,23 +3,26 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|||||||
import 'package:tatlock_ui/core/api/api_interceptors.dart';
|
import 'package:tatlock_ui/core/api/api_interceptors.dart';
|
||||||
import 'package:tatlock_ui/core/config/app_config.dart';
|
import 'package:tatlock_ui/core/config/app_config.dart';
|
||||||
|
|
||||||
|
import 'api_client_native.dart' if (dart.library.html) 'api_client_web.dart'
|
||||||
|
as platform;
|
||||||
|
|
||||||
part 'api_client.g.dart';
|
part 'api_client.g.dart';
|
||||||
|
|
||||||
/// Provides the Dio instance for Core API.
|
/// Provides the Dio instance for Core API.
|
||||||
@riverpod
|
@riverpod
|
||||||
Dio coreApiClient(Ref ref) {
|
Dio coreApiClient(Ref ref) {
|
||||||
final dio = Dio(
|
final options = BaseOptions(
|
||||||
BaseOptions(
|
baseUrl: AppConfig.coreApiUrl,
|
||||||
baseUrl: AppConfig.coreApiUrl,
|
connectTimeout: const Duration(seconds: 10),
|
||||||
connectTimeout: const Duration(seconds: 10),
|
receiveTimeout: const Duration(seconds: 30),
|
||||||
receiveTimeout: const Duration(seconds: 30),
|
headers: {
|
||||||
headers: {
|
'Content-Type': 'application/json',
|
||||||
'Content-Type': 'application/json',
|
'Accept': 'application/json',
|
||||||
'Accept': 'application/json',
|
},
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final dio = platform.createDio(options);
|
||||||
|
|
||||||
dio.interceptors.addAll([
|
dio.interceptors.addAll([
|
||||||
AuthInterceptor(ref),
|
AuthInterceptor(ref),
|
||||||
LoggingInterceptor(),
|
LoggingInterceptor(),
|
||||||
@@ -32,18 +35,18 @@ Dio coreApiClient(Ref ref) {
|
|||||||
/// Provides the Dio instance for Tatlock API.
|
/// Provides the Dio instance for Tatlock API.
|
||||||
@riverpod
|
@riverpod
|
||||||
Dio tatlockApiClient(Ref ref) {
|
Dio tatlockApiClient(Ref ref) {
|
||||||
final dio = Dio(
|
final options = BaseOptions(
|
||||||
BaseOptions(
|
baseUrl: AppConfig.tatlockApiUrl,
|
||||||
baseUrl: AppConfig.tatlockApiUrl,
|
connectTimeout: const Duration(seconds: 10),
|
||||||
connectTimeout: const Duration(seconds: 10),
|
receiveTimeout: const Duration(minutes: 5), // Longer for LLM responses
|
||||||
receiveTimeout: const Duration(minutes: 5), // Longer for LLM responses
|
headers: {
|
||||||
headers: {
|
'Content-Type': 'application/json',
|
||||||
'Content-Type': 'application/json',
|
'Accept': 'application/json',
|
||||||
'Accept': 'application/json',
|
},
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final dio = platform.createDio(options);
|
||||||
|
|
||||||
dio.interceptors.addAll([
|
dio.interceptors.addAll([
|
||||||
AuthInterceptor(ref),
|
AuthInterceptor(ref),
|
||||||
LoggingInterceptor(),
|
LoggingInterceptor(),
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
/// Create a Dio instance for native platforms (mobile, desktop).
|
||||||
|
Dio createDio(BaseOptions options) {
|
||||||
|
return Dio(options);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:dio_web_adapter/dio_web_adapter.dart';
|
||||||
|
|
||||||
|
/// Create a Dio instance for web platform with credentials support.
|
||||||
|
///
|
||||||
|
/// Enables `withCredentials` to send cookies with requests, which is
|
||||||
|
/// required for NPM forward auth to work correctly.
|
||||||
|
Dio createDio(BaseOptions options) {
|
||||||
|
final dio = Dio(options);
|
||||||
|
dio.httpClientAdapter = BrowserHttpClientAdapter(withCredentials: true);
|
||||||
|
return dio;
|
||||||
|
}
|
||||||
@@ -102,7 +102,7 @@ class AuthDatasource {
|
|||||||
/// Returns user profile if authenticated via the proxy.
|
/// Returns user profile if authenticated via the proxy.
|
||||||
/// Throws 401 if not authenticated or accessing directly.
|
/// Throws 401 if not authenticated or accessing directly.
|
||||||
Future<AuthSyncResponse> getCurrentUser() async {
|
Future<AuthSyncResponse> getCurrentUser() async {
|
||||||
final response = await _dio.get<Map<String, dynamic>>('/auth/me');
|
final response = await _dio.get<Map<String, dynamic>>('/auth/users/me');
|
||||||
return AuthSyncResponse.fromJson(response.data!);
|
return AuthSyncResponse.fromJson(response.data!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ part 'auth_provider.g.dart';
|
|||||||
/// - **Web**: NPM forward auth with Authentik (cookies handled by proxy)
|
/// - **Web**: NPM forward auth with Authentik (cookies handled by proxy)
|
||||||
/// - **Mobile**: OIDC Authorization Code flow with flutter_appauth
|
/// - **Mobile**: OIDC Authorization Code flow with flutter_appauth
|
||||||
///
|
///
|
||||||
/// On web, the app calls GET /auth/me to check if user is authenticated
|
/// On web, the app calls GET /auth/users/me to check if user is authenticated
|
||||||
/// via NPM forward auth headers. On mobile, uses OIDC flow then POST /auth/sync.
|
/// via NPM forward auth headers. On mobile, uses OIDC flow then POST /auth/sync.
|
||||||
@riverpod
|
@riverpod
|
||||||
class AuthNotifier extends _$AuthNotifier {
|
class AuthNotifier extends _$AuthNotifier {
|
||||||
@@ -59,7 +59,7 @@ class AuthNotifier extends _$AuthNotifier {
|
|||||||
/// Returns AuthState if authenticated, null if not.
|
/// Returns AuthState if authenticated, null if not.
|
||||||
Future<AuthState?> _tryWebAuth() async {
|
Future<AuthState?> _tryWebAuth() async {
|
||||||
try {
|
try {
|
||||||
developer.log('Attempting web auth via /auth/me', name: 'auth');
|
developer.log('Attempting web auth via /auth/users/me', name: 'auth');
|
||||||
final authDatasource = ref.read(authDatasourceProvider);
|
final authDatasource = ref.read(authDatasourceProvider);
|
||||||
final syncResponse = await authDatasource.getCurrentUser();
|
final syncResponse = await authDatasource.getCurrentUser();
|
||||||
|
|
||||||
@@ -238,7 +238,7 @@ class AuthNotifier extends _$AuthNotifier {
|
|||||||
// User needs to access via the authenticated proxy URL
|
// User needs to access via the authenticated proxy URL
|
||||||
if (kIsWeb) {
|
if (kIsWeb) {
|
||||||
developer.log('Web sign-in: user should access via authenticated proxy', name: 'auth');
|
developer.log('Web sign-in: user should access via authenticated proxy', name: 'auth');
|
||||||
// Try to refresh auth state from /auth/me
|
// Try to refresh auth state from /auth/users/me
|
||||||
state = const AsyncLoading();
|
state = const AsyncLoading();
|
||||||
final webAuth = await _tryWebAuth();
|
final webAuth = await _tryWebAuth();
|
||||||
if (webAuth != null) {
|
if (webAuth != null) {
|
||||||
|
|||||||
+2
-1
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.0.0+1
|
version: 1.0.4+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.4
|
sdk: ^3.10.4
|
||||||
@@ -67,6 +67,7 @@ dependencies:
|
|||||||
url_launcher: ^6.3.1
|
url_launcher: ^6.3.1
|
||||||
flutter_code_editor: ^0.3.5
|
flutter_code_editor: ^0.3.5
|
||||||
highlight: ^0.7.0
|
highlight: ^0.7.0
|
||||||
|
dio_web_adapter: ^2.1.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env dart
|
||||||
|
// Generates web/health.json from pubspec.yaml
|
||||||
|
// Run: dart run tool/generate_health_json.dart
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:yaml/yaml.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final pubspecFile = File('pubspec.yaml');
|
||||||
|
if (!pubspecFile.existsSync()) {
|
||||||
|
stderr.writeln('Error: pubspec.yaml not found');
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
final pubspecContent = pubspecFile.readAsStringSync();
|
||||||
|
final pubspec = loadYaml(pubspecContent) as YamlMap;
|
||||||
|
|
||||||
|
final name = pubspec['name'] as String;
|
||||||
|
final description = pubspec['description'] as String? ?? '';
|
||||||
|
final versionString = pubspec['version'] as String;
|
||||||
|
|
||||||
|
// Parse version: "1.0.3+1" -> version="1.0.3", buildNumber=1
|
||||||
|
final versionParts = versionString.split('+');
|
||||||
|
final version = versionParts[0];
|
||||||
|
final buildNumber = versionParts.length > 1 ? int.parse(versionParts[1]) : 0;
|
||||||
|
|
||||||
|
final health = {
|
||||||
|
'status': 'healthy',
|
||||||
|
'name': name,
|
||||||
|
'title': description,
|
||||||
|
'version': version,
|
||||||
|
'buildNumber': buildNumber,
|
||||||
|
'fullVersion': '$version+$buildNumber',
|
||||||
|
};
|
||||||
|
|
||||||
|
final webDir = Directory('web');
|
||||||
|
if (!webDir.existsSync()) {
|
||||||
|
webDir.createSync(recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
final healthFile = File('web/health.json');
|
||||||
|
healthFile.writeAsStringSync(
|
||||||
|
const JsonEncoder.withIndent(' ').convert(health),
|
||||||
|
);
|
||||||
|
|
||||||
|
print('Generated web/health.json with version $version+$buildNumber');
|
||||||
|
}
|
||||||
+25
-2
@@ -1,7 +1,30 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>OK</title>
|
<title>Health Check</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: monospace; padding: 20px; background: #1a1a1a; color: #0f0; }
|
||||||
|
.healthy { color: #0f0; }
|
||||||
|
.error { color: #f00; }
|
||||||
|
pre { background: #222; padding: 15px; border-radius: 5px; }
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>OK</body>
|
<body>
|
||||||
|
<h1 id="status">Loading...</h1>
|
||||||
|
<pre id="data"></pre>
|
||||||
|
<script>
|
||||||
|
fetch('/health.json')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
document.getElementById('status').textContent = data.status?.toUpperCase() || 'OK';
|
||||||
|
document.getElementById('status').className = data.status === 'healthy' ? 'healthy' : 'error';
|
||||||
|
document.getElementById('data').textContent = JSON.stringify(data, null, 2);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
document.getElementById('status').textContent = 'ERROR';
|
||||||
|
document.getElementById('status').className = 'error';
|
||||||
|
document.getElementById('data').textContent = err.message;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"status": "healthy",
|
||||||
|
"name": "tatlock_ui",
|
||||||
|
"title": "Tatlock - a Home Lab AI",
|
||||||
|
"version": "1.0.4",
|
||||||
|
"buildNumber": 1,
|
||||||
|
"fullVersion": "1.0.4+1"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user