Commit a0d8b3b8 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(caprover): sorting, live log viewer — and fix broken per-app URLs

SORTING by last deployed (default), name, status or version. Apps with no deploy
timestamp sort last rather than as the epoch: "unknown" is not "ancient".
Building sorts above running above stopped, because the one that is changing is
the one you opened the tab for.

LIVE LOG VIEWER for both build output and runtime logs, polled every 3s.
CapRover exposes no log stream, and a poll is what survives a phone moving
between networks anyway. Follow-mode sticks to the bottom until you scroll up
and stops fighting you — reading history while lines arrive is the normal reason
to open a log. Build logs stop polling once the build ends rather than hitting
CapRover every three seconds forever. Errors and warnings are coloured, lines
are filterable, and the whole buffer copies in one tap.

FIXED, and this one was live: every per-app CapRover URL had an escaped dollar
sign — '/api/caprover/apps/\$name' — so the path was sent with a literal
backslash-dollar-name instead of the app. It came from a heredoc when the client
was first written. capApps() has no interpolation, so listing worked and I
verified against it; inspect, deploy, set-repo and logs were all broken and none
of them had been exercised. Testing the one endpoint that happened to work is
not testing the feature.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent c50315c4
...@@ -457,6 +457,43 @@ class CapRepo { ...@@ -457,6 +457,43 @@ class CapRepo {
); );
} }
class CapLogLine {
const CapLogLine({required this.at, required this.text});
final String at;
final String text;
/// Docker's timestamp is UTC and full-precision; the list only has room for
/// clock time.
String get clock => at.length >= 19 ? at.substring(11, 19) : '';
factory CapLogLine.fromJson(Map<String, dynamic> j) =>
CapLogLine(at: _s(j['at']), text: _s(j['text']));
}
class CapBuild {
const CapBuild({
required this.building,
required this.failed,
required this.cursor,
required this.lines,
});
final bool building;
final bool failed;
/// CapRover's own cursor into the build output. It moving is how a restarted
/// build is told from one that simply has nothing new.
final int cursor;
final List<CapLogLine> lines;
factory CapBuild.fromJson(Map<String, dynamic> j) => CapBuild(
building: _b(j['building']),
failed: _b(j['failed']),
cursor: _i(j['firstLineNumber']),
lines: _list(j['lines'], CapLogLine.fromJson),
);
}
class CapApp { class CapApp {
const CapApp({ const CapApp({
required this.name, required this.name,
...@@ -465,6 +502,8 @@ class CapApp { ...@@ -465,6 +502,8 @@ class CapApp {
required this.building, required this.building,
required this.instanceCount, required this.instanceCount,
required this.repo, required this.repo,
this.lastDeployedAt,
this.gitHash = '',
this.hasPersistentData = false, this.hasPersistentData = false,
this.ssl = false, this.ssl = false,
this.forceSsl = false, this.forceSsl = false,
...@@ -480,6 +519,11 @@ class CapApp { ...@@ -480,6 +519,11 @@ class CapApp {
final bool building; final bool building;
final int instanceCount; final int instanceCount;
final CapRepo repo; final CapRepo repo;
/// When the running version was deployed. Null for apps whose version carries
/// no timestamp, which must sort last rather than as the epoch.
final DateTime? lastDeployedAt;
final String gitHash;
final bool hasPersistentData; final bool hasPersistentData;
final bool ssl; final bool ssl;
final bool forceSsl; final bool forceSsl;
...@@ -501,6 +545,8 @@ class CapApp { ...@@ -501,6 +545,8 @@ class CapApp {
repo: j['repo'] is Map repo: j['repo'] is Map
? CapRepo.fromJson((j['repo'] as Map).cast<String, dynamic>()) ? CapRepo.fromJson((j['repo'] as Map).cast<String, dynamic>())
: const CapRepo(), : const CapRepo(),
lastDeployedAt: _s(j['lastDeployedAt']).isEmpty ? null : _dt(j['lastDeployedAt']),
gitHash: _s(j['gitHash']),
hasPersistentData: _b(j['hasPersistentData']), hasPersistentData: _b(j['hasPersistentData']),
ssl: _b(j['ssl']), ssl: _b(j['ssl']),
forceSsl: _b(j['forceSsl']), forceSsl: _b(j['forceSsl']),
......
...@@ -300,13 +300,13 @@ class SonApi { ...@@ -300,13 +300,13 @@ class SonApi {
CapOverview.fromJson(_obj(await _get('/api/caprover/apps'))); CapOverview.fromJson(_obj(await _get('/api/caprover/apps')));
Future<CapApp> capApp(String name) async { Future<CapApp> capApp(String name) async {
final j = _obj(await _get('/api/caprover/apps/\$name')); final j = _obj(await _get('/api/caprover/apps/$name'));
return CapApp.fromJson((j['app'] as Map).cast<String, dynamic>()); return CapApp.fromJson((j['app'] as Map).cast<String, dynamic>());
} }
/// Rebuild from the app's configured git repo, or deploy a specific image. /// Rebuild from the app's configured git repo, or deploy a specific image.
Future<void> capDeploy(String name, {String? image}) async => Future<void> capDeploy(String name, {String? image}) async =>
_send('POST', '/api/caprover/apps/\$name/deploy', _send('POST', '/api/caprover/apps/$name/deploy',
body: {if (image != null && image.isNotEmpty) 'image': image}); body: {if (image != null && image.isNotEmpty) 'image': image});
Future<void> capSetRepo( Future<void> capSetRepo(
...@@ -316,7 +316,7 @@ class SonApi { ...@@ -316,7 +316,7 @@ class SonApi {
String? user, String? user,
String? password, String? password,
}) async => }) async =>
_send('POST', '/api/caprover/apps/\$name/repo', body: { _send('POST', '/api/caprover/apps/$name/repo', body: {
'url': url, 'url': url,
if (branch != null) 'branch': branch, if (branch != null) 'branch': branch,
if (user != null) 'user': user, if (user != null) 'user': user,
...@@ -324,9 +324,21 @@ class SonApi { ...@@ -324,9 +324,21 @@ class SonApi {
if (password != null && password.isNotEmpty) 'password': password, if (password != null && password.isNotEmpty) 'password': password,
}); });
Future<String> capLogs(String name) async { /// The build that produced the current version.
final j = _obj(await _get('/api/caprover/apps/\$name/logs')); Future<CapBuild> capBuild(String name) async =>
return (j['logs'] ?? '').toString(); CapBuild.fromJson(_obj(await _get('/api/caprover/apps/$name/logs')));
/// What the running container is saying right now — a different question from
/// the build log, and the one that matters when a deploy has gone wrong.
Future<List<CapLogLine>> capRuntimeLogs(String name) async {
final j = _obj(await _get('/api/caprover/apps/$name/runtime-logs'));
final l = j['lines'];
return l is List
? [
for (final e in l)
if (e is Map) CapLogLine.fromJson(e.cast<String, dynamic>()),
]
: const [];
} }
// --- usage ---------------------------------------------------------------- // --- usage ----------------------------------------------------------------
......
...@@ -7,6 +7,7 @@ import '../../core/api/son_api.dart'; ...@@ -7,6 +7,7 @@ import '../../core/api/son_api.dart';
import '../../core/providers.dart'; import '../../core/providers.dart';
import '../../core/theme.dart'; import '../../core/theme.dart';
import '../../widgets/empty_state.dart'; import '../../widgets/empty_state.dart';
import 'log_viewer.dart';
final capProvider = FutureProvider<CapOverview>((ref) async => ref.watch(apiProvider).capApps()); final capProvider = FutureProvider<CapOverview>((ref) async => ref.watch(apiProvider).capApps());
...@@ -26,6 +27,7 @@ class _CapRoverScreenState extends ConsumerState<CapRoverScreen> { ...@@ -26,6 +27,7 @@ class _CapRoverScreenState extends ConsumerState<CapRoverScreen> {
final _search = TextEditingController(); final _search = TextEditingController();
String _query = ''; String _query = '';
bool _onlyGit = false; bool _onlyGit = false;
_Sort _sort = _Sort.deployed;
@override @override
void dispose() { void dispose() {
...@@ -83,6 +85,7 @@ class _CapRoverScreenState extends ConsumerState<CapRoverScreen> { ...@@ -83,6 +85,7 @@ class _CapRoverScreenState extends ConsumerState<CapRoverScreen> {
a.image.toLowerCase().contains(q) || a.image.toLowerCase().contains(q) ||
a.repo.url.toLowerCase().contains(q); a.repo.url.toLowerCase().contains(q);
}).toList(); }).toList();
_apply(_sort, apps);
return Column( return Column(
children: [ children: [
...@@ -101,6 +104,45 @@ class _CapRoverScreenState extends ConsumerState<CapRoverScreen> { ...@@ -101,6 +104,45 @@ class _CapRoverScreenState extends ConsumerState<CapRoverScreen> {
Row( Row(
children: [ children: [
const SizedBox(width: 16), const SizedBox(width: 16),
PopupMenuButton<_Sort>(
initialValue: _sort,
color: Ember.surface,
onSelected: (v) => setState(() => _sort = v),
itemBuilder: (_) => [
for (final s in _Sort.values)
PopupMenuItem(
value: s,
child: Row(
children: [
if (_sort == s)
const Icon(Icons.check, size: 15, color: Ember.flame)
else
const SizedBox(width: 15),
const SizedBox(width: 8),
Text(s.label, style: const TextStyle(fontSize: 13)),
],
),
),
],
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Ember.surfaceHi,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: Ember.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.sort, size: 13, color: Ember.flame),
const SizedBox(width: 6),
Text(_sort.label, style: const TextStyle(fontSize: 12)),
const Icon(Icons.expand_more, size: 14, color: Ember.textFaint),
],
),
),
),
const SizedBox(width: 8),
FilterChip( FilterChip(
label: const Text('Deploys from git'), label: const Text('Deploys from git'),
selected: _onlyGit, selected: _onlyGit,
...@@ -266,50 +308,13 @@ class _AppCardState extends ConsumerState<_AppCard> { ...@@ -266,50 +308,13 @@ class _AppCardState extends ConsumerState<_AppCard> {
); );
} }
Future<void> _logs() async { void _showLogs(LogKind kind) {
try { showModalBottomSheet(
final text = await _api.capLogs(a.name); context: context,
if (!mounted) return; backgroundColor: Ember.surface,
showModalBottomSheet( isScrollControlled: true,
context: context, builder: (_) => LogViewer(app: a.name, kind: kind),
backgroundColor: Ember.surface, );
isScrollControlled: true,
builder: (_) => DraggableScrollableSheet(
expand: false,
initialChildSize: 0.85,
builder: (_, ctrl) => Column(
children: [
const SizedBox(height: 14),
Text('${a.name} · build log',
style: const TextStyle(fontWeight: FontWeight.w600)),
const SizedBox(height: 10),
Expanded(
child: text.trim().isEmpty
? const Center(
child: Text('No build output', style: TextStyle(color: Ember.textDim)))
: SingleChildScrollView(
controller: ctrl,
padding: const EdgeInsets.all(14),
child: SelectableText(
text,
style: const TextStyle(
fontSize: 11,
height: 1.4,
fontFamilyFallback: Ember.mono,
color: Ember.textDim,
),
),
),
),
],
),
),
);
} on ApiException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message)));
}
}
} }
@override @override
...@@ -361,6 +366,11 @@ class _AppCardState extends ConsumerState<_AppCard> { ...@@ -361,6 +366,11 @@ class _AppCardState extends ConsumerState<_AppCard> {
style: const TextStyle( style: const TextStyle(
fontSize: 11, color: Ember.textDim, fontFamilyFallback: Ember.mono), fontSize: 11, color: Ember.textDim, fontFamilyFallback: Ember.mono),
), ),
if (a.lastDeployedAt != null) ...[
const SizedBox(height: 4),
Text('deployed ${_ago(a.lastDeployedAt!)}',
style: const TextStyle(fontSize: 10.5, color: Ember.textFaint)),
],
if (a.repo.isSet) ...[ if (a.repo.isSet) ...[
const SizedBox(height: 5), const SizedBox(height: 5),
Row( Row(
...@@ -461,7 +471,10 @@ class _AppCardState extends ConsumerState<_AppCard> { ...@@ -461,7 +471,10 @@ class _AppCardState extends ConsumerState<_AppCard> {
children: [ children: [
_act(Icons.rocket_launch_outlined, 'Redeploy', _deploy), _act(Icons.rocket_launch_outlined, 'Redeploy', _deploy),
_act(Icons.commit, 'Source', _editRepo), _act(Icons.commit, 'Source', _editRepo),
_act(Icons.article_outlined, 'Log', _logs), _act(Icons.construction, 'Build log',
() => _showLogs(LogKind.build)),
_act(Icons.terminal, 'Live log',
() => _showLogs(LogKind.runtime)),
], ],
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
...@@ -501,3 +514,49 @@ class _AppCardState extends ConsumerState<_AppCard> { ...@@ -501,3 +514,49 @@ class _AppCardState extends ConsumerState<_AppCard> {
), ),
); );
} }
enum _Sort {
deployed('Last deployed'),
name('Name'),
status('Status'),
version('Version');
const _Sort(this.label);
final String label;
}
/// Sorts in place. Apps with no deploy timestamp sort last rather than as the
/// epoch — "unknown" is not "ancient".
void _apply(_Sort sort, List<CapApp> apps) {
switch (sort) {
case _Sort.deployed:
apps.sort((x, y) {
final a = x.lastDeployedAt, b = y.lastDeployedAt;
if (a == null && b == null) return x.name.compareTo(y.name);
if (a == null) return 1;
if (b == null) return -1;
return b.compareTo(a);
});
case _Sort.name:
apps.sort((x, y) => x.name.compareTo(y.name));
case _Sort.status:
// Building first (it is changing), then running, then stopped.
int rank(CapApp a) => a.building ? 0 : (a.stopped ? 2 : 1);
apps.sort((x, y) {
final r = rank(x).compareTo(rank(y));
return r != 0 ? r : x.name.compareTo(y.name);
});
case _Sort.version:
apps.sort((x, y) => y.deployedVersion.compareTo(x.deployedVersion));
}
}
String _ago(DateTime t) {
final d = DateTime.now().difference(t);
if (d.inMinutes < 1) return 'just now';
if (d.inMinutes < 60) return '${d.inMinutes}m ago';
if (d.inHours < 24) return '${d.inHours}h ago';
if (d.inDays < 30) return '${d.inDays}d ago';
return '${(d.inDays / 30).floor()}mo ago';
}
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/models.dart';
import '../../core/api/son_api.dart';
import '../../core/providers.dart';
import '../../core/theme.dart';
enum LogKind { build, runtime }
/// Live log tail for one app.
///
/// Polls rather than streams: CapRover exposes no log stream, and a poll is what
/// survives a phone moving between networks anyway. Follow-mode sticks to the
/// bottom until you scroll up, then stops fighting you — reading history while
/// new lines arrive is the normal reason to open this.
class LogViewer extends ConsumerStatefulWidget {
const LogViewer({super.key, required this.app, required this.kind});
final String app;
final LogKind kind;
@override
ConsumerState<LogViewer> createState() => _LogViewerState();
}
class _LogViewerState extends ConsumerState<LogViewer> {
final _scroll = ScrollController();
List<CapLogLine> _lines = const [];
Timer? _timer;
bool _follow = true;
bool _live = true;
bool _loading = true;
String? _error;
bool _building = false;
bool _failed = false;
String _filter = '';
SonApi get _api => ref.read(apiProvider);
@override
void initState() {
super.initState();
_scroll.addListener(_onScroll);
_tick();
_timer = Timer.periodic(const Duration(seconds: 3), (_) {
if (_live) _tick();
});
}
@override
void dispose() {
_timer?.cancel();
_scroll.removeListener(_onScroll);
_scroll.dispose();
super.dispose();
}
void _onScroll() {
if (!_scroll.hasClients) return;
// Leaving the bottom means the reader wants to stay put; returning to it
// opts back in. No toggle to remember, no surprise jumps.
final atEnd = _scroll.position.pixels >= _scroll.position.maxScrollExtent - 40;
if (atEnd != _follow) setState(() => _follow = atEnd);
}
Future<void> _tick() async {
try {
if (widget.kind == LogKind.runtime) {
final l = await _api.capRuntimeLogs(widget.app);
if (!mounted) return;
setState(() {
_lines = l;
_loading = false;
_error = null;
});
} else {
final b = await _api.capBuild(widget.app);
if (!mounted) return;
setState(() {
_lines = b.lines;
_building = b.building;
_failed = b.failed;
_loading = false;
_error = null;
// A finished build has nothing further to say; stop polling rather
// than hitting CapRover every three seconds forever.
if (!b.building) _live = false;
});
}
if (_follow) _stickToEnd();
} on ApiException catch (e) {
if (!mounted) return;
setState(() {
_loading = false;
_error = e.message;
});
}
}
void _stickToEnd() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scroll.hasClients && _follow) {
_scroll.jumpTo(_scroll.position.maxScrollExtent);
}
});
}
List<CapLogLine> get _visible {
final q = _filter.trim().toLowerCase();
if (q.isEmpty) return _lines;
return _lines.where((l) => l.text.toLowerCase().contains(q)).toList();
}
/// Errors and warnings are worth spotting without reading every line.
Color _colorFor(String t) {
final s = t.toLowerCase();
if (s.contains('error') || s.contains('fatal') || s.contains('exception') ||
s.contains('failed') || s.contains('panic')) {
return Ember.bad;
}
if (s.contains('warn')) return Ember.amber;
if (s.contains('listening') || s.contains('started') || s.contains('ready') ||
s.contains('success')) {
return Ember.ok;
}
return Ember.textDim;
}
@override
Widget build(BuildContext context) {
final lines = _visible;
final isBuild = widget.kind == LogKind.build;
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.9,
builder: (_, __) => Column(
children: [
const SizedBox(height: 12),
Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: Ember.border,
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 8, 6),
child: Row(
children: [
Icon(isBuild ? Icons.construction : Icons.terminal,
size: 15, color: Ember.flame),
const SizedBox(width: 8),
Expanded(
child: Text(
'${widget.app} · ${isBuild ? 'build' : 'runtime'}',
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14),
),
),
if (isBuild && _building)
const _Dot(color: Ember.amber, label: 'building')
else if (isBuild && _failed)
const _Dot(color: Ember.bad, label: 'failed')
else if (_live)
const _Dot(color: Ember.ok, label: 'live'),
IconButton(
tooltip: _live ? 'Pause' : 'Resume',
icon: Icon(_live ? Icons.pause : Icons.play_arrow, size: 19),
onPressed: () {
setState(() => _live = !_live);
if (_live) _tick();
},
),
IconButton(
tooltip: 'Copy all',
icon: const Icon(Icons.copy, size: 17),
onPressed: () {
Clipboard.setData(ClipboardData(
text: _lines.map((l) => '${l.at} ${l.text}').join('\n')));
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Log copied')));
},
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: TextField(
onChanged: (v) => setState(() => _filter = v),
style: const TextStyle(fontSize: 13),
decoration: InputDecoration(
isDense: true,
hintText: 'Filter lines…',
prefixIcon: const Icon(Icons.search, size: 17),
suffixText: '${lines.length}/${_lines.length}',
suffixStyle: const TextStyle(fontSize: 10, color: Ember.textFaint),
),
),
),
const SizedBox(height: 8),
Expanded(
child: _loading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(_error!,
textAlign: TextAlign.center,
style: const TextStyle(color: Ember.bad, fontSize: 13)),
),
)
: lines.isEmpty
? const Center(
child: Text('No output',
style: TextStyle(color: Ember.textDim)))
: Container(
color: const Color(0xFF08080B),
child: ListView.builder(
controller: _scroll,
padding: const EdgeInsets.fromLTRB(12, 8, 12, 20),
itemCount: lines.length,
itemBuilder: (_, i) {
final l = lines[i];
return Padding(
padding: const EdgeInsets.only(bottom: 2),
child: SelectableText.rich(
TextSpan(children: [
if (l.clock.isNotEmpty)
TextSpan(
text: '${l.clock} ',
style: const TextStyle(
color: Ember.textFaint, fontSize: 10),
),
TextSpan(
text: l.text,
style: TextStyle(
color: _colorFor(l.text), fontSize: 11),
),
]),
style: const TextStyle(
height: 1.4, fontFamilyFallback: Ember.mono),
),
);
},
),
),
),
if (!_follow)
Padding(
padding: const EdgeInsets.only(bottom: 10, top: 4),
child: TextButton.icon(
onPressed: () {
setState(() => _follow = true);
_stickToEnd();
},
icon: const Icon(Icons.arrow_downward, size: 15),
label: const Text('Jump to latest', style: TextStyle(fontSize: 12)),
style: TextButton.styleFrom(foregroundColor: Ember.flame),
),
),
],
),
);
}
}
class _Dot extends StatelessWidget {
const _Dot({required this.color, required this.label});
final Color color;
final String label;
@override
Widget build(BuildContext context) => Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 5),
Text(label, style: TextStyle(fontSize: 10.5, color: color)),
],
);
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment