Added Agent Availability screen

This commit is contained in:
2026-08-31 15:27:26 +02:00
parent e7508a4c8e
commit 0561109e3c
8 changed files with 171 additions and 30 deletions
+7 -7
View File
@@ -2,22 +2,22 @@
///
/// See the note in `EAgentType.dart` about ordering and [unknown].
enum EAgentStatus {
offline,
idle,
available,
working,
offline,
error,
unknown;
bool get isOnline => this == EAgentStatus.idle || this == EAgentStatus.working;
bool get isOnline => this == EAgentStatus.available || this == EAgentStatus.working;
String get displayName {
switch (this) {
case EAgentStatus.offline:
return 'Offline';
case EAgentStatus.idle:
return 'Idle';
case EAgentStatus.available:
return 'Available';
case EAgentStatus.working:
return 'Working';
case EAgentStatus.offline:
return 'Offline';
case EAgentStatus.error:
return 'Error';
case EAgentStatus.unknown:
+18 -12
View File
@@ -5,23 +5,29 @@
/// so that it never shadows a real backend value and acts as fallback for
/// values this client does not know yet.
enum EAgentType {
developer,
tester,
reviewer,
manager,
acApi,
logAnalyzer,
coder,
researcher,
securityAnalyst,
journalist,
unknown;
/// Human readable label, e.g. for chips or table cells.
String get displayName {
switch (this) {
case EAgentType.developer:
return 'Developer';
case EAgentType.tester:
return 'Tester';
case EAgentType.reviewer:
return 'Reviewer';
case EAgentType.manager:
return 'Manager';
case EAgentType.acApi:
return 'AC Api';
case EAgentType.logAnalyzer:
return 'Log Analyzer';
case EAgentType.coder:
return 'Coder';
case EAgentType.researcher:
return 'Researcher';
case EAgentType.securityAnalyst:
return 'Security Analyst';
case EAgentType.journalist:
return 'Journalist';
case EAgentType.unknown:
return 'Unknown';
}
+7 -1
View File
@@ -123,8 +123,9 @@ class JsonReader {
}
final text = raw.toString().trim().toLowerCase();
final normalized = _normalizeEnumName(text);
for (final candidate in values) {
if (nameOf(candidate).toLowerCase() == text) {
if (_normalizeEnumName(nameOf(candidate).toLowerCase()) == normalized) {
return candidate;
}
}
@@ -135,4 +136,9 @@ class JsonReader {
}
return fallback;
}
/// Removes separators so that `AC_API` also matches `acApi`.
static String _normalizeEnumName(String value) {
return value.replaceAll(RegExp(r'[^a-z0-9]'), '');
}
}
+3 -2
View File
@@ -1,3 +1,4 @@
import 'package:ac_web/Pages/AgentAvailabilities.dart';
import 'package:ac_web/Pages/LatestReports.dart';
import 'package:flutter/material.dart';
@@ -43,8 +44,8 @@ class MobileViewState extends State<MobileView> {
// Eighteen
LatestReports(),
// Notifications
Text("Notifications")
// Agents
AgentAvailabilities()
][currentPageIndex],
);
}
+12
View File
@@ -0,0 +1,12 @@
import 'package:ac_web/Widgets/AgentAvailabilityColumn.dart';
import 'package:flutter/material.dart';
/// Page that lists all agents together with their current availability.
class AgentAvailabilities extends StatelessWidget {
const AgentAvailabilities({super.key});
@override
Widget build(BuildContext context) {
return const AgentAvailabilityColumn();
}
}
@@ -0,0 +1,77 @@
import 'package:ac_web/Api/AcApi.dart';
import 'package:ac_web/Api/Dtos/AgentDto.dart';
import 'package:ac_web/Widgets/AgentStatusIndicator.dart';
import 'package:flutter/material.dart';
/// Fetches all agents from the AgentsController and lists them with a colored
/// status indicator in front of their name.
class AgentAvailabilityColumn extends StatefulWidget{
const AgentAvailabilityColumn({super.key});
@override
State<AgentAvailabilityColumn> createState() => _AgentAvailabilityColumnState();
}
class _AgentAvailabilityColumnState extends State<AgentAvailabilityColumn>{
late Future<List<AgentDto>> mAgentsFuture;
@override
void initState() {
super.initState();
mAgentsFuture = AcApi.instance.agents.getAgents();
}
void ReloadAgents(){
setState(() {
mAgentsFuture = AcApi.instance.agents.getAgents();
});
}
@override
Widget build(BuildContext context) {
return FutureBuilder<List<AgentDto>>(
future: mAgentsFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Could not load agents: ${snapshot.error}'));
}
// The list of agents fetched from the AgentsController.
final List<AgentDto> agents = snapshot.data ?? <AgentDto>[];
return _buildList(agents);
},
);
}
Widget _buildList(List<AgentDto> agents) {
if (agents.isEmpty) {
return const Center(child: Text("No agents available"));
}
return RefreshIndicator(
onRefresh: () async => ReloadAgents(),
child: ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: agents.length,
itemBuilder: (context, index) {
final AgentDto agent = agents[index];
return ListTile(
leading: AgentStatusIndicator(status: agent.status),
title: Text(agent.name),
subtitle: Text("${agent.type.displayName} - ${agent.status.displayName}"),
);
},
),
);
}
}
@@ -0,0 +1,39 @@
import 'package:ac_web/Api/Dtos/EAgentStatus.dart';
import 'package:flutter/material.dart';
/// Small colored circle that visualizes the current [EAgentStatus] of an agent.
///
/// Green = available, blue = working, red = error, white = unavailable.
class AgentStatusIndicator extends StatelessWidget {
const AgentStatusIndicator({super.key, required this.status, this.size = 16});
final EAgentStatus status;
final double size;
static Color colorOf(EAgentStatus status) {
switch (status) {
case EAgentStatus.available:
return Colors.green;
case EAgentStatus.working:
return Colors.blue;
case EAgentStatus.error:
return Colors.red;
case EAgentStatus.offline:
case EAgentStatus.unknown:
return Colors.white;
}
}
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorOf(status),
shape: BoxShape.circle,
border: Border.all(color: Colors.black26),
),
);
}
}
+8 -8
View File
@@ -31,9 +31,9 @@ void main() {
jsonEncode([
{
'name': 'Ada',
'type': 'Developer',
'type': 'CODER',
'lastSeen': '2026-08-28T12:00:00Z',
'status': 'Working',
'status': 'WORKING',
'tasks': [
{'name': 'Implement api layer', 'status': 'InProgress', 'progress': 42},
],
@@ -47,7 +47,7 @@ void main() {
expect(requested.toString(), 'http://api.local:5000/api/agents');
expect(agents, hasLength(1));
expect(agents.single.name, 'Ada');
expect(agents.single.type, EAgentType.developer);
expect(agents.single.type, EAgentType.coder);
expect(agents.single.status, EAgentStatus.working);
expect(agents.single.lastSeen.isUtc, isTrue);
expect(agents.single.tasks.single.name, 'Implement api layer');
@@ -76,15 +76,15 @@ void main() {
final agents = await api.agents.getAgents();
expect(agents.first.type, EAgentType.tester);
expect(agents.first.status, EAgentStatus.working);
expect(agents.first.type, EAgentType.logAnalyzer);
expect(agents.first.status, EAgentStatus.offline);
expect(agents.first.tasks, isEmpty);
expect(agents.last.type, EAgentType.unknown);
expect(agents.last.status, EAgentStatus.unknown);
});
test('throws ApiParsingException on a malformed payload', () async {
final api = apiReturning(jsonEncode([{'Type': 'Developer'}]));
final api = apiReturning(jsonEncode([{'Type': 'CODER'}]));
expect(() => api.agents.getAgents(), throwsA(isA<ApiParsingException>()));
});
@@ -147,10 +147,10 @@ void main() {
test('agent survives toJson/fromJson', () {
final original = AgentDto(
name: 'Ada',
type: EAgentType.reviewer,
type: EAgentType.securityAnalyst,
lastSeen: DateTime.utc(2026, 8, 28, 12),
tasks: const [AgentTaskDto(name: 'Review', status: 'Open')],
status: EAgentStatus.idle,
status: EAgentStatus.available,
);
final restored = AgentDto.fromJson(original.toJson());