feat: unified rail shell, robust live sync, task board performance
Shell: - One shared NexusLayout for ALL routes (dashboard + pages): compact 68px icon rail with hover-expand overlay, replaces both old sidebars + topbars - Single flat nav source (railNav) — same menu everywhere incl. Settings - Removed dead shell components (AppSidebar, AppHeader, Topbar, NavGroup, NavItem, ModuleView) and dead nav routes; App.vue is now just RouterView Live updates: - Fix SSE loop in DashboardController: PeriodicTimer.WaitForNextTickAsync and ChannelReader.ReadAsync were re-invoked while pending — every published update threw InvalidOperationException and killed ALL live streams - liveSync store: treat graceful stream close as disconnect (was stuck connected=true with polling stopped -> page frozen until manual reload), fast first retry, heartbeat watchdog (65s), reconnect on online/visibility - One app-wide SSE connection owned by the layout instead of per-view connect/disconnect churn; removed duplicate live-sync.ts store Performance: - Board endpoint: drop nested childTasks duplication (counts stay) — payload 104KB -> 65KB; SSE snapshots shrink equally - nginx: gzip for JSON/JS/CSS (board 18KB, bundle 95KB over the wire); text/event-stream excluded to keep SSE unbuffered Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -236,38 +236,60 @@ public class DashboardController(
|
|||||||
var subscription = await liveUpdateService.SubscribeAsync(afterSequence, ct);
|
var subscription = await liveUpdateService.SubscribeAsync(afterSequence, ct);
|
||||||
using var heartbeat = new PeriodicTimer(TimeSpan.FromSeconds(20));
|
using var heartbeat = new PeriodicTimer(TimeSpan.FromSeconds(20));
|
||||||
|
|
||||||
while (!ct.IsCancellationRequested)
|
// PeriodicTimer erlaubt nur EIN ausstehendes WaitForNextTickAsync und der
|
||||||
|
// Channel-Reader (SingleReader) nur EIN ausstehendes ReadAsync. Beide Tasks
|
||||||
|
// werden deshalb außerhalb der Schleife gehalten und nur der jeweils
|
||||||
|
// abgeschlossene erneuert — sonst stirbt der Stream beim ersten Update
|
||||||
|
// mit einer InvalidOperationException.
|
||||||
|
var readTask = subscription.Reader.ReadAsync(ct).AsTask();
|
||||||
|
var heartbeatTask = heartbeat.WaitForNextTickAsync(ct).AsTask();
|
||||||
|
|
||||||
|
try
|
||||||
{
|
{
|
||||||
var readTask = subscription.Reader.ReadAsync(ct).AsTask();
|
while (!ct.IsCancellationRequested)
|
||||||
var heartbeatTask = heartbeat.WaitForNextTickAsync(ct).AsTask();
|
|
||||||
var completed = await Task.WhenAny(readTask, heartbeatTask);
|
|
||||||
|
|
||||||
if (completed == readTask)
|
|
||||||
{
|
{
|
||||||
var envelope = await readTask;
|
var completed = await Task.WhenAny(readTask, heartbeatTask);
|
||||||
if (envelope.Type == "notifications.snapshot")
|
|
||||||
{
|
|
||||||
var snapshot = envelope.Payload as NotificationSnapshotDto
|
|
||||||
?? await notificationService.GetSnapshotAsync(forUser, notificationLimit, ct: ct);
|
|
||||||
if (!string.Equals(snapshot.ForUser, forUser, StringComparison.OrdinalIgnoreCase))
|
|
||||||
continue;
|
|
||||||
envelope = envelope with { Payload = snapshot };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (envelope.Type == "tasks.board.snapshot")
|
if (completed == readTask)
|
||||||
{
|
{
|
||||||
envelope = envelope with { Payload = await taskService.GetBoardAsync(ct) };
|
var envelope = await readTask;
|
||||||
}
|
readTask = subscription.Reader.ReadAsync(ct).AsTask();
|
||||||
|
|
||||||
await WriteEventAsync("update", new DashboardLiveEventDto(
|
if (envelope.Type == "notifications.snapshot")
|
||||||
envelope,
|
{
|
||||||
new LiveCursorDto(envelope.Sequence, envelope.Timestamp, "live")));
|
var snapshot = envelope.Payload as NotificationSnapshotDto
|
||||||
}
|
?? await notificationService.GetSnapshotAsync(forUser, notificationLimit, ct: ct);
|
||||||
else if (await heartbeatTask)
|
if (!string.Equals(snapshot.ForUser, forUser, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
continue;
|
||||||
await WriteEventAsync("heartbeat", new LiveCursorDto(liveUpdateService.CurrentSequence, DateTimeOffset.UtcNow, "live"));
|
envelope = envelope with { Payload = snapshot };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.Type == "tasks.board.snapshot")
|
||||||
|
{
|
||||||
|
envelope = envelope with { Payload = await taskService.GetBoardAsync(ct) };
|
||||||
|
}
|
||||||
|
|
||||||
|
await WriteEventAsync("update", new DashboardLiveEventDto(
|
||||||
|
envelope,
|
||||||
|
new LiveCursorDto(envelope.Sequence, envelope.Timestamp, "live")));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var ticked = await heartbeatTask;
|
||||||
|
heartbeatTask = heartbeat.WaitForNextTickAsync(ct).AsTask();
|
||||||
|
if (!ticked) break;
|
||||||
|
await WriteEventAsync("heartbeat", new LiveCursorDto(liveUpdateService.CurrentSequence, DateTimeOffset.UtcNow, "live"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Client hat die Verbindung beendet — normal.
|
||||||
|
}
|
||||||
|
catch (System.Threading.Channels.ChannelClosedException)
|
||||||
|
{
|
||||||
|
// Subscription serverseitig geschlossen — Stream regulär beenden.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPatch("tasks/{id:guid}/move")]
|
[HttpPatch("tasks/{id:guid}/move")]
|
||||||
|
|||||||
@@ -431,7 +431,9 @@ public sealed class TaskService(
|
|||||||
|
|
||||||
foreach (var task in all)
|
foreach (var task in all)
|
||||||
{
|
{
|
||||||
var dto = MapToDtoWithChildren(task, all, activity);
|
// Ohne verschachtelte Child-DTOs: Children sind als eigene Karten im Board,
|
||||||
|
// die Nested-Duplikate haben die Payload nur verdoppelt (Counts bleiben).
|
||||||
|
var dto = MapToDtoWithChildren(task, all, activity, includeChildren: false);
|
||||||
switch (task.State.ToLowerInvariant())
|
switch (task.State.ToLowerInvariant())
|
||||||
{
|
{
|
||||||
case "backlog": offen.Add(dto); break;
|
case "backlog": offen.Add(dto); break;
|
||||||
@@ -513,22 +515,26 @@ public sealed class TaskService(
|
|||||||
return all.Where(e => e.TaskId == taskId).ToList();
|
return all.Where(e => e.TaskId == taskId).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private DashboardTaskDto MapToDtoWithChildren(WorkTask task, IReadOnlyList<WorkTask> allTasks, IEnumerable<ActivityEvent> activity)
|
private DashboardTaskDto MapToDtoWithChildren(WorkTask task, IReadOnlyList<WorkTask> allTasks, IEnumerable<ActivityEvent> activity, bool includeChildren = true)
|
||||||
{
|
{
|
||||||
var childTasks = allTasks.Where(t => t.ParentTaskId == task.Id)
|
var childTasks = allTasks.Where(t => t.ParentTaskId == task.Id)
|
||||||
.OrderByDescending(t => t.UpdatedAt)
|
.OrderByDescending(t => t.UpdatedAt)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var childDtos = childTasks.Select(child => MapToDtoWithActivity(child, activity, allTasks)).ToList();
|
// includeChildren=false (Board/SSE-Snapshot): Children erscheinen dort ohnehin
|
||||||
|
// als eigene Karten — verschachtelte Child-DTOs verdoppeln nur die Payload.
|
||||||
|
var childDtos = includeChildren
|
||||||
|
? childTasks.Select(child => MapToDtoWithActivity(child, activity, allTasks)).ToList()
|
||||||
|
: null;
|
||||||
var openChildTaskCount = childTasks.Count(child => !string.Equals(child.State, "Done", StringComparison.OrdinalIgnoreCase));
|
var openChildTaskCount = childTasks.Count(child => !string.Equals(child.State, "Done", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
var dto = MapToDtoWithActivity(task, activity, allTasks);
|
var dto = MapToDtoWithActivity(task, activity, allTasks);
|
||||||
return dto with
|
return dto with
|
||||||
{
|
{
|
||||||
ChildTasks = childDtos,
|
ChildTasks = childDtos,
|
||||||
ChildTaskCount = childDtos.Count,
|
ChildTaskCount = childTasks.Count,
|
||||||
OpenChildTaskCount = openChildTaskCount,
|
OpenChildTaskCount = openChildTaskCount,
|
||||||
HasVisibleDelegation = dto.ParentTaskId.HasValue || childDtos.Count > 0 || dto.IsAgentTask
|
HasVisibleDelegation = dto.ParentTaskId.HasValue || childTasks.Count > 0 || dto.IsAgentTask
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# Kompression für Bundle + API-JSON (Board-Payload ~100 KB → wenige KB).
|
||||||
|
# text/event-stream bewusst NICHT in gzip_types: gzip würde den SSE-Stream puffern.
|
||||||
|
gzip on;
|
||||||
|
gzip_comp_level 5;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_types application/json application/javascript text/css text/javascript image/svg+xml;
|
||||||
|
|
||||||
add_header Content-Security-Policy "default-src 'self'; connect-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
add_header Content-Security-Policy "default-src 'self'; connect-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||||
add_header Referrer-Policy "no-referrer" always;
|
add_header Referrer-Policy "no-referrer" always;
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
|||||||
+8
-150
@@ -1,156 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
/**
|
||||||
import { Activity } from '@lucide/vue'
|
* App — nur noch Router-Einstieg + Toasts.
|
||||||
import { RouterView, useRoute, useRouter } from 'vue-router'
|
* Die Shell (Rail, Hintergrund, Live-Sync) lebt in layouts/NexusLayout.vue
|
||||||
import { useOperationsStore } from './stores/operations'
|
* und umschließt alle Seiten außer dem Login.
|
||||||
import { useAuthStore } from './stores/auth'
|
*/
|
||||||
import AppSidebar from './components/layout/AppSidebar.vue'
|
import { RouterView } from 'vue-router'
|
||||||
import AppHeader from './components/layout/AppHeader.vue'
|
|
||||||
import GalaxyBackground from './components/background/GalaxyBackground.vue'
|
|
||||||
import ModuleView from './components/ModuleView.vue'
|
|
||||||
import ToastContainer from './components/ui/ToastContainer.vue'
|
import ToastContainer from './components/ui/ToastContainer.vue'
|
||||||
|
|
||||||
const store = useOperationsStore()
|
|
||||||
const auth = useAuthStore()
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const activeView = computed(() => {
|
|
||||||
if (route.name === 'Settings') return 'Settings'
|
|
||||||
if (route.name === 'ProjectDetail') return 'ProjectDetail'
|
|
||||||
return String(route.name ?? 'Dashboard')
|
|
||||||
})
|
|
||||||
|
|
||||||
const routePaths: Record<string, string> = {
|
|
||||||
Dashboard: '/dashboard', Memory: '/memory', Docs: '/docs', Security: '/security',
|
|
||||||
Projects: '/projects', 'Task Board': '/tasks', Incidents: '/incidents', Calendar: '/calendar',
|
|
||||||
Agents: '/agents', Models: '/models', Activity: '/activity', 'Mobile Chat': '/chat', Notifications: '/notifications', Settings: '/settings',
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigate = (label: string) => {
|
|
||||||
mobileNavOpen.value = false
|
|
||||||
return router.push(routePaths[label] ?? '/dashboard')
|
|
||||||
}
|
|
||||||
const mobileNavOpen = ref(false)
|
|
||||||
|
|
||||||
const standaloneViews = computed(() => {
|
|
||||||
if (route.name === 'Dashboard') return true
|
|
||||||
if (route.meta?.standalone) return true
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (auth.isAuthenticated) store.refresh()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<RouterView v-if="route.name === 'Login' || route.name === 'Dashboard'" />
|
<RouterView />
|
||||||
<div v-else class="shell">
|
<ToastContainer />
|
||||||
<GalaxyBackground />
|
|
||||||
<AppSidebar
|
|
||||||
:active-view="activeView"
|
|
||||||
:mobile-nav-open="mobileNavOpen"
|
|
||||||
:queued-tasks="store.snapshot.metrics.queuedTasks"
|
|
||||||
:incidents="store.snapshot.metrics.incidents"
|
|
||||||
@navigate="navigate"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<AppHeader
|
|
||||||
:connected="store.connected"
|
|
||||||
@toggle-mobile-nav="mobileNavOpen = !mobileNavOpen"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section class="content">
|
|
||||||
<RouterView v-if="standaloneViews" />
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div class="page-heading">
|
|
||||||
<div>
|
|
||||||
<span class="eyebrow">MISSION CONTROL</span>
|
|
||||||
<h1>{{ activeView }}</h1>
|
|
||||||
<p>System overview and operational intelligence across Noveria.</p>
|
|
||||||
</div>
|
|
||||||
<button class="refresh" @click="store.refresh()">
|
|
||||||
<Activity :size="15" :class="{ spin: store.loading }" />
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ModuleView
|
|
||||||
:view="activeView"
|
|
||||||
:snapshot="store.snapshot"
|
|
||||||
:routing="store.routing"
|
|
||||||
@create-project="store.createProject"
|
|
||||||
@create-task="store.createTask"
|
|
||||||
@update-task-state="store.updateTaskState"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
<ToastContainer />
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.shell {
|
|
||||||
display: flex;
|
|
||||||
height: 100vh;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-width: 0;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-heading {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.page-heading h1 { margin: 0; font-size: 18px; }
|
|
||||||
.page-heading p { margin: 4px 0 0; font-size: 10px; color: var(--nx-text-dim); }
|
|
||||||
.eyebrow {
|
|
||||||
font-size: 8.5px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: .12em;
|
|
||||||
color: var(--nx-accent);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.refresh {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 6px 11px;
|
|
||||||
border: 1px solid var(--nx-line);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--nx-text-dim);
|
|
||||||
font-size: 9px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background .15s;
|
|
||||||
}
|
|
||||||
.refresh:hover { background: var(--nx-accent-soft); color: #d8dbe3; }
|
|
||||||
|
|
||||||
.spin { animation: spin 1s linear infinite; }
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
|
||||||
.kanban { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,741 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
|
||||||
import { Bot, CheckCircle2, Clock3, MessageSquareText, Send, ShieldAlert, Zap, ChevronLeft, ChevronRight, Edit2, Save, X, Trash2 } from '@lucide/vue'
|
|
||||||
import type { AgentInfo, OperationsSnapshot, RoutingTarget } from '../types'
|
|
||||||
import { TASK_STATES } from '../types'
|
|
||||||
import { apiFetch } from '../services/api'
|
|
||||||
import { useAuthStore } from '../stores/auth'
|
|
||||||
import { useOperationsStore, type PendingApprovalTask } from '../stores/operations'
|
|
||||||
|
|
||||||
const props = defineProps<{ view: string; snapshot: OperationsSnapshot; routing: RoutingTarget[] }>()
|
|
||||||
const emit = defineEmits<{
|
|
||||||
createProject: [name: string]
|
|
||||||
createTask: [title: string, priority: string]
|
|
||||||
updateTaskState: [id: string, state: string]
|
|
||||||
}>()
|
|
||||||
const store = useOperationsStore()
|
|
||||||
const auth = useAuthStore()
|
|
||||||
const agents = ref<AgentInfo[]>([])
|
|
||||||
const agentsLoading = ref(false)
|
|
||||||
const pendingApprovals = ref<PendingApprovalTask[]>([])
|
|
||||||
const pendingApprovalsLoading = ref(false)
|
|
||||||
const pendingApprovalsError = ref('')
|
|
||||||
const canModerateApprovals = computed(() => auth.user?.role === 'owner')
|
|
||||||
|
|
||||||
async function loadAgents() {
|
|
||||||
if (agentsLoading.value) return
|
|
||||||
agentsLoading.value = true
|
|
||||||
agents.value = await store.fetchAgents()
|
|
||||||
agentsLoading.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadPendingApprovals() {
|
|
||||||
if (!canModerateApprovals.value) {
|
|
||||||
pendingApprovals.value = []
|
|
||||||
pendingApprovalsError.value = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingApprovalsLoading.value = true
|
|
||||||
pendingApprovalsError.value = ''
|
|
||||||
try {
|
|
||||||
pendingApprovals.value = await store.fetchPendingApprovals()
|
|
||||||
} catch (e) {
|
|
||||||
pendingApprovalsError.value = e instanceof Error ? e.message : 'Failed to load pending approvals'
|
|
||||||
} finally {
|
|
||||||
pendingApprovalsLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (props.view === 'Agents') loadAgents()
|
|
||||||
if (props.view === 'Task Board') void loadPendingApprovals()
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => props.view, (v) => {
|
|
||||||
if (v === 'Agents') loadAgents()
|
|
||||||
if (v === 'Task Board') void loadPendingApprovals()
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(canModerateApprovals, (value) => {
|
|
||||||
if (props.view !== 'Task Board') return
|
|
||||||
if (value) {
|
|
||||||
void loadPendingApprovals()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingApprovals.value = []
|
|
||||||
pendingApprovalsError.value = ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const newProject = ref('')
|
|
||||||
const newTask = ref('')
|
|
||||||
const message = ref('')
|
|
||||||
const chatMessages = ref<Array<{ role: 'owner' | 'iris' | 'error'; content: string }>>([])
|
|
||||||
const chatPending = ref(false)
|
|
||||||
const conversationId = ref(localStorage.getItem('nexus-conversation-id') ?? crypto.randomUUID())
|
|
||||||
localStorage.setItem('nexus-conversation-id', conversationId.value)
|
|
||||||
|
|
||||||
// Task editing state
|
|
||||||
const editingTaskId = ref<string | null>(null)
|
|
||||||
|
|
||||||
// Task approval / rejection state
|
|
||||||
const approvingTaskId = ref<string | null>(null)
|
|
||||||
const taskActionError = ref('')
|
|
||||||
|
|
||||||
async function handleApproveTask(id: string) {
|
|
||||||
approvingTaskId.value = id
|
|
||||||
taskActionError.value = ''
|
|
||||||
try {
|
|
||||||
await store.approveTask(id)
|
|
||||||
pendingApprovals.value = pendingApprovals.value.filter(task => task.id !== id)
|
|
||||||
} catch (e) {
|
|
||||||
taskActionError.value = e instanceof Error ? e.message : 'Failed to approve task'
|
|
||||||
} finally {
|
|
||||||
approvingTaskId.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRejectTask(id: string) {
|
|
||||||
approvingTaskId.value = id
|
|
||||||
taskActionError.value = ''
|
|
||||||
try {
|
|
||||||
await store.rejectTask(id)
|
|
||||||
pendingApprovals.value = pendingApprovals.value.filter(task => task.id !== id)
|
|
||||||
} catch (e) {
|
|
||||||
taskActionError.value = e instanceof Error ? e.message : 'Failed to reject task'
|
|
||||||
} finally {
|
|
||||||
approvingTaskId.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Task deletion state
|
|
||||||
const deletingTaskId = ref<string | null>(null)
|
|
||||||
const deleteError = ref('')
|
|
||||||
|
|
||||||
async function confirmDeleteTask(id: string) {
|
|
||||||
deleteError.value = ''
|
|
||||||
try {
|
|
||||||
await store.deleteTask(id)
|
|
||||||
deletingTaskId.value = null
|
|
||||||
} catch (e) {
|
|
||||||
deleteError.value = e instanceof Error ? e.message : 'Failed to delete task'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelDeleteTask() {
|
|
||||||
deletingTaskId.value = null
|
|
||||||
deleteError.value = ''
|
|
||||||
}
|
|
||||||
const editTaskTitle = ref('')
|
|
||||||
const editTaskPriority = ref('')
|
|
||||||
const editTaskProjectId = ref<string | null>(null)
|
|
||||||
|
|
||||||
// Activity filtering and pagination
|
|
||||||
const activityTypeFilter = ref('')
|
|
||||||
const activitySort = ref('newest')
|
|
||||||
const activityPage = ref(1)
|
|
||||||
const activityPageSize = 20
|
|
||||||
const activityTotalPages = ref(1)
|
|
||||||
const activityTotalCount = ref(0)
|
|
||||||
|
|
||||||
const columns = computed(() =>
|
|
||||||
TASK_STATES.map(state => ({ name: state, items: props.snapshot.tasks.filter(x => x.state === state) })))
|
|
||||||
|
|
||||||
const availableTypes = computed(() => {
|
|
||||||
const types = new Set(props.snapshot.activity.map(e => e.type))
|
|
||||||
return Array.from(types)
|
|
||||||
})
|
|
||||||
|
|
||||||
const filteredActivity = computed(() => {
|
|
||||||
let items = [...props.snapshot.activity]
|
|
||||||
if (activityTypeFilter.value) {
|
|
||||||
items = items.filter(e => e.type === activityTypeFilter.value)
|
|
||||||
}
|
|
||||||
if (activitySort.value === 'oldest') {
|
|
||||||
items.reverse()
|
|
||||||
}
|
|
||||||
const total = items.length
|
|
||||||
activityTotalCount.value = total
|
|
||||||
activityTotalPages.value = Math.max(1, Math.ceil(total / activityPageSize))
|
|
||||||
const start = (activityPage.value - 1) * activityPageSize
|
|
||||||
return items.slice(start, start + activityPageSize)
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(activityTypeFilter, () => { activityPage.value = 1 })
|
|
||||||
watch(activitySort, () => { activityPage.value = 1 })
|
|
||||||
|
|
||||||
function startEditTask(task: { id: string; title: string; priority: string; projectId?: string | null }) {
|
|
||||||
editingTaskId.value = task.id
|
|
||||||
editTaskTitle.value = task.title
|
|
||||||
editTaskPriority.value = task.priority
|
|
||||||
editTaskProjectId.value = task.projectId ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveEditTask(id: string) {
|
|
||||||
try {
|
|
||||||
await store.updateTask(id, {
|
|
||||||
title: editTaskTitle.value.trim() || undefined,
|
|
||||||
priority: editTaskPriority.value || undefined,
|
|
||||||
projectId: editTaskProjectId.value || undefined,
|
|
||||||
})
|
|
||||||
editingTaskId.value = null
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to update task', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelEditTask() {
|
|
||||||
editingTaskId.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendMessage() {
|
|
||||||
const value = message.value.trim()
|
|
||||||
if (!value || chatPending.value) return
|
|
||||||
chatMessages.value.push({ role: 'owner', content: value })
|
|
||||||
message.value = ''
|
|
||||||
chatPending.value = true
|
|
||||||
try {
|
|
||||||
const response = await apiFetch('/api/v1/chat', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ message: value, conversationId: conversationId.value, agentId: 'iris' }),
|
|
||||||
})
|
|
||||||
const payload = await response.json()
|
|
||||||
if (!response.ok) throw new Error(payload.detail ?? 'Iris is currently unavailable.')
|
|
||||||
conversationId.value = payload.conversationId
|
|
||||||
localStorage.setItem('nexus-conversation-id', payload.conversationId)
|
|
||||||
chatMessages.value.push({ role: 'iris', content: payload.content })
|
|
||||||
} catch (error) {
|
|
||||||
chatMessages.value.push({ role: 'error', content: error instanceof Error ? error.message : 'Iris is currently unavailable.' })
|
|
||||||
} finally {
|
|
||||||
chatPending.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<form v-if="view === 'Projects'" class="quick-create" @submit.prevent="newProject.trim() && (emit('createProject', newProject.trim()), newProject = '')"><input v-model="newProject" placeholder="New project name" /><button>Create project</button></form>
|
|
||||||
<div v-if="view === 'Projects'" class="module-grid">
|
|
||||||
<article v-for="project in snapshot.projects" :key="project.id" class="module-card project-card" @click="$router.push(`/projects/${project.id}`)">
|
|
||||||
<div class="module-card-head"><span class="project-letter">{{ project.name[0] }}</span><span class="badge positive">{{ project.status }}</span></div>
|
|
||||||
<h3>{{ project.name }}</h3><p>Operational workspace managed through Nexus.</p>
|
|
||||||
<div class="progress"><i :style="{ width: `${project.progress}%` }"></i></div>
|
|
||||||
<footer><span>Progress</span><strong>{{ project.progress }}%</strong></footer>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form v-else-if="view === 'Task Board'" class="quick-create" @submit.prevent="newTask.trim() && (emit('createTask', newTask.trim(), 'Normal'), newTask = '')"><input v-model="newTask" placeholder="New task title" /><button>Create task</button></form>
|
|
||||||
<section v-if="view === 'Task Board' && canModerateApprovals" class="approval-strip">
|
|
||||||
<header class="approval-strip-head">
|
|
||||||
<div>
|
|
||||||
<span class="kicker">Owner approvals</span>
|
|
||||||
<h3>Pending approvals</h3>
|
|
||||||
</div>
|
|
||||||
<span class="badge">{{ pendingApprovals.length }}</span>
|
|
||||||
</header>
|
|
||||||
<p v-if="pendingApprovalsLoading" class="approval-strip-note">Loading owner approval queue…</p>
|
|
||||||
<p v-else-if="pendingApprovalsError" class="approval-strip-note error">{{ pendingApprovalsError }}</p>
|
|
||||||
<p v-else-if="!pendingApprovals.length" class="approval-strip-note">No tasks are waiting for Bao approval.</p>
|
|
||||||
<div v-else class="approval-list">
|
|
||||||
<article v-for="task in pendingApprovals" :key="task.id" class="approval-card">
|
|
||||||
<div>
|
|
||||||
<strong>{{ task.title }}</strong>
|
|
||||||
<p>{{ task.priority }} · {{ new Date(task.updatedAt).toLocaleString() }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="approval-actions">
|
|
||||||
<button class="task-approve-btn" :disabled="approvingTaskId === task.id" @click="handleApproveTask(task.id)"><CheckCircle2 :size="13" /></button>
|
|
||||||
<button class="task-reject-btn" :disabled="approvingTaskId === task.id" @click="handleRejectTask(task.id)"><X :size="13" /></button>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
<p v-if="taskActionError" class="approval-strip-note error">{{ taskActionError }}</p>
|
|
||||||
</section>
|
|
||||||
<div v-if="view === 'Task Board'" class="kanban">
|
|
||||||
<section v-for="column in columns" :key="column.name" class="kanban-column">
|
|
||||||
<header><span>{{ column.name }}</span><b>{{ column.items.length }}</b></header>
|
|
||||||
<article v-for="task in column.items" :key="task.id" class="task-card">
|
|
||||||
<template v-if="editingTaskId === task.id">
|
|
||||||
<input v-model="editTaskTitle" class="task-edit-input" placeholder="Task title" maxlength="240" />
|
|
||||||
<div class="task-edit-row">
|
|
||||||
<select v-model="editTaskPriority">
|
|
||||||
<option value="Critical">Critical</option>
|
|
||||||
<option value="High">High</option>
|
|
||||||
<option value="Normal">Normal</option>
|
|
||||||
<option value="Low">Low</option>
|
|
||||||
</select>
|
|
||||||
<select v-model="editTaskProjectId">
|
|
||||||
<option :value="null">No project</option>
|
|
||||||
<option v-for="p in snapshot.projects" :key="p.id" :value="p.id">{{ p.name }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="task-edit-actions">
|
|
||||||
<button class="task-edit-save" @click="saveEditTask(task.id)"><Save :size="13" /> Save</button>
|
|
||||||
<button class="task-edit-cancel" @click="cancelEditTask"><X :size="13" /> Cancel</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<div class="task-card-head">
|
|
||||||
<span :class="['priority', task.priority.toLowerCase()]">{{ task.priority }}</span>
|
|
||||||
<div class="task-card-actions">
|
|
||||||
<template v-if="task.state === 'In progress' && canModerateApprovals">
|
|
||||||
<button
|
|
||||||
class="task-approve-btn"
|
|
||||||
title="Approve"
|
|
||||||
:disabled="approvingTaskId === task.id"
|
|
||||||
@click="handleApproveTask(task.id)"
|
|
||||||
><CheckCircle2 :size="13" /></button>
|
|
||||||
<button
|
|
||||||
class="task-reject-btn"
|
|
||||||
title="Reject"
|
|
||||||
:disabled="approvingTaskId === task.id"
|
|
||||||
@click="handleRejectTask(task.id)"
|
|
||||||
><X :size="13" /></button>
|
|
||||||
</template>
|
|
||||||
<button class="task-edit-btn" @click="startEditTask(task)" title="Edit task"><Edit2 :size="12" /></button>
|
|
||||||
<button
|
|
||||||
v-if="task.state === 'Done' || task.state === 'Backlog'"
|
|
||||||
class="task-delete-btn"
|
|
||||||
title="Delete task"
|
|
||||||
@click="deletingTaskId = task.id; deleteError = ''"
|
|
||||||
><Trash2 :size="12" /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h3>{{ task.title }}</h3>
|
|
||||||
<select :value="task.state" @change="emit('updateTaskState', task.id, ($event.target as HTMLSelectElement).value)">
|
|
||||||
<option v-for="state in TASK_STATES" :key="state" :value="state">{{ state }}</option>
|
|
||||||
</select>
|
|
||||||
<footer><Clock3 :size="13" /> {{ new Date(task.updatedAt).toLocaleString() }}</footer>
|
|
||||||
</template>
|
|
||||||
</article>
|
|
||||||
<div v-if="!column.items.length" class="empty-state">No tasks</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="view === 'Agents'" class="module-grid">
|
|
||||||
<div v-if="agentsLoading" class="loading-agents">Loading agents…</div>
|
|
||||||
<article v-for="agent in agents" :key="agent.id" class="module-card agent-card">
|
|
||||||
<div class="agent-avatar" :class="agent.role === 'orchestrator' ? 'violet' : ''">
|
|
||||||
<Bot v-if="agent.role === 'orchestrator'" :size="22" />
|
|
||||||
<Zap v-else :size="22" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="kicker">{{ agent.role.toUpperCase() }}</span>
|
|
||||||
<h3>{{ agent.name }}</h3>
|
|
||||||
<p>{{ agent.description || agent.model }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="agent-status-group">
|
|
||||||
<span v-if="agent.model" class="agent-model-tag">{{ agent.model.replace(/^[^/]*\//, '') }}</span>
|
|
||||||
<span :class="['badge', agent.status === 'Online' ? 'positive' : agent.status === 'Degraded' ? 'warning' : 'negative']">{{ agent.status }}</span>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
<div v-if="!agentsLoading && !agents.length" class="empty-state">No agents available</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="view === 'Models'" class="module-list panel">
|
|
||||||
<div v-for="model in routing" :key="model.model" class="model-detail">
|
|
||||||
<div class="route-rank">0{{ model.priority }}</div><div><span class="kicker">{{ model.purpose }}</span><h3>{{ model.model }}</h3><p>{{ model.provider }} · {{ model.detail }}</p></div><span :class="['badge', model.status === 'Online' ? 'positive' : 'warning']">{{ model.status }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="view === 'Activity'" class="activity-panel panel">
|
|
||||||
<div class="activity-filters">
|
|
||||||
<div class="filter-group">
|
|
||||||
<label>Type</label>
|
|
||||||
<select v-model="activityTypeFilter">
|
|
||||||
<option value="">All types</option>
|
|
||||||
<option v-for="type in availableTypes" :key="type" :value="type">{{ type }}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="filter-group">
|
|
||||||
<label>Sort</label>
|
|
||||||
<select v-model="activitySort">
|
|
||||||
<option value="newest">Newest first</option>
|
|
||||||
<option value="oldest">Oldest first</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="timeline">
|
|
||||||
<article v-for="event in filteredActivity" :key="event.message + event.at">
|
|
||||||
<div :class="['timeline-icon', event.type]">
|
|
||||||
<CheckCircle2 v-if="event.type !== 'security'" :size="15" />
|
|
||||||
<ShieldAlert v-else :size="15" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="kicker">{{ event.type }}</span>
|
|
||||||
<h3>{{ event.message }}</h3>
|
|
||||||
<p>{{ new Date(event.at).toLocaleString() }}</p>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
<div v-if="activityTotalPages > 1" class="activity-pagination">
|
|
||||||
<button :disabled="activityPage <= 1" @click="activityPage--"><ChevronLeft :size="14" /></button>
|
|
||||||
<span>{{ activityPage }} / {{ activityTotalPages }}</span>
|
|
||||||
<button :disabled="activityPage >= activityTotalPages" @click="activityPage++"><ChevronRight :size="14" /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="view === 'Settings'" class="settings-redirect">
|
|
||||||
<p>Use the <router-link to="/settings">full Settings page</router-link> for profile management and password changes.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="view === 'Mobile Chat'" class="chat-shell panel">
|
|
||||||
<header><div class="agent-avatar"><MessageSquareText :size="20" /></div><div><h3>Iris Mobile</h3><p>Secure owner operations channel</p></div><span class="badge warning">Preview</span></header>
|
|
||||||
<div class="messages"><div class="message iris"><strong>Iris</strong><p>Nexus is online. Messages are routed through the OpenClaw runtime.</p></div><div v-for="(item, index) in chatMessages" :key="index" :class="['message', item.role]"><strong>{{ item.role === 'owner' ? 'Owner' : item.role === 'iris' ? 'Iris' : 'Runtime' }}</strong><p>{{ item.content }}</p></div><div v-if="chatPending" class="message iris pending"><strong>Iris</strong><p>Working...</p></div></div>
|
|
||||||
<form @submit.prevent="sendMessage"><input v-model="message" :disabled="chatPending" placeholder="Ask for status or create a task..." /><button :disabled="chatPending"><Send :size="15" /></button></form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Task deletion confirmation dialog -->
|
|
||||||
<Teleport to="body">
|
|
||||||
<div v-if="deletingTaskId" class="delete-overlay" @click.self="cancelDeleteTask">
|
|
||||||
<div class="delete-dialog">
|
|
||||||
<h3>Delete Task?</h3>
|
|
||||||
<p>This action cannot be undone. The task will be permanently removed.</p>
|
|
||||||
<p v-if="deleteError" class="delete-error">{{ deleteError }}</p>
|
|
||||||
<div class="delete-actions">
|
|
||||||
<button class="delete-cancel" @click="cancelDeleteTask">Cancel</button>
|
|
||||||
<button class="delete-confirm" @click="confirmDeleteTask(deletingTaskId)">Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.approval-strip {
|
|
||||||
margin: 0 0 18px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
border: 1px solid var(--line, #1e2030);
|
|
||||||
border-radius: 14px;
|
|
||||||
background: rgba(255,255,255,.025);
|
|
||||||
}
|
|
||||||
.approval-strip-head {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.approval-strip-head h3 {
|
|
||||||
margin: 2px 0 0;
|
|
||||||
}
|
|
||||||
.approval-strip-note {
|
|
||||||
margin: 10px 0 0;
|
|
||||||
color: #8e96a8;
|
|
||||||
}
|
|
||||||
.approval-strip-note.error {
|
|
||||||
color: #e16e75;
|
|
||||||
}
|
|
||||||
.approval-list {
|
|
||||||
display: grid;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
.approval-card {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
border: 1px solid rgba(255,255,255,.06);
|
|
||||||
border-radius: 12px;
|
|
||||||
background: rgba(8, 10, 18, .35);
|
|
||||||
}
|
|
||||||
.approval-card p {
|
|
||||||
margin: 4px 0 0;
|
|
||||||
color: #8e96a8;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.approval-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.task-card-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
.task-card-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.15rem;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.task-edit-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.15rem;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
.task-card:hover .task-edit-btn {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
.task-edit-btn:hover {
|
|
||||||
color: var(--nx-accent);
|
|
||||||
}
|
|
||||||
.task-delete-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.15rem;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.15s, color 0.15s;
|
|
||||||
}
|
|
||||||
.task-card:hover .task-delete-btn {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
.task-delete-btn:hover {
|
|
||||||
color: var(--danger, #e74c3c);
|
|
||||||
}
|
|
||||||
.task-edit-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.35rem 0.5rem;
|
|
||||||
background: var(--surface-raised);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--text-primary);
|
|
||||||
margin-bottom: 0.4rem;
|
|
||||||
}
|
|
||||||
.task-edit-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.35rem;
|
|
||||||
margin-bottom: 0.4rem;
|
|
||||||
}
|
|
||||||
.task-edit-row select {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0.25rem 0.4rem;
|
|
||||||
background: var(--surface-raised);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
.task-edit-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.35rem;
|
|
||||||
}
|
|
||||||
.task-edit-save {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
background: var(--nx-accent);
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.task-edit-cancel {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
background: var(--surface-raised);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.activity-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
.activity-filters {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.filter-group {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
|
||||||
.filter-group label {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.filter-group select {
|
|
||||||
padding: 0.35rem 0.5rem;
|
|
||||||
background: var(--surface-raised);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
.activity-pagination {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding-top: 0.75rem;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.activity-pagination button {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.3rem 0.5rem;
|
|
||||||
background: var(--surface-raised);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.activity-pagination button:disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
.activity-pagination span {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
.project-card {
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.15s, box-shadow 0.15s;
|
|
||||||
}
|
|
||||||
.project-card:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
.settings-redirect {
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
.settings-redirect a {
|
|
||||||
color: var(--nx-accent);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.settings-redirect a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Task deletion confirmation */
|
|
||||||
.delete-overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
.delete-dialog {
|
|
||||||
background: var(--surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
max-width: 380px;
|
|
||||||
width: 90%;
|
|
||||||
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
|
|
||||||
}
|
|
||||||
.delete-dialog h3 {
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
.delete-dialog p {
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
.delete-error {
|
|
||||||
color: var(--danger, #e74c3c) !important;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.delete-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
.delete-cancel {
|
|
||||||
padding: 0.45rem 1rem;
|
|
||||||
background: var(--surface-raised);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
.delete-confirm {
|
|
||||||
padding: 0.45rem 1rem;
|
|
||||||
background: var(--danger, #e74c3c);
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.delete-confirm:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Agent card enhancements */
|
|
||||||
.agent-status-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 0.3rem;
|
|
||||||
}
|
|
||||||
.agent-model-tag {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
background: var(--surface-raised);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0.1rem 0.35rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
max-width: 120px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.loading-agents {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
text-align: center;
|
|
||||||
padding: 2rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Task approve/reject buttons */
|
|
||||||
.task-approve-btn,
|
|
||||||
.task-reject-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.15rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.15s, color 0.15s;
|
|
||||||
}
|
|
||||||
.task-card:hover .task-approve-btn,
|
|
||||||
.task-card:hover .task-reject-btn {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
.task-approve-btn {
|
|
||||||
color: var(--success, #27ae60);
|
|
||||||
}
|
|
||||||
.task-approve-btn:hover {
|
|
||||||
color: var(--success, #27ae60);
|
|
||||||
filter: brightness(1.2);
|
|
||||||
}
|
|
||||||
.task-reject-btn {
|
|
||||||
color: var(--warning, #f39c12);
|
|
||||||
}
|
|
||||||
.task-reject-btn:hover {
|
|
||||||
color: var(--danger, #e74c3c);
|
|
||||||
}
|
|
||||||
.task-approve-btn:disabled,
|
|
||||||
.task-reject-btn:disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { Command, Search, CircleDot, Sparkles } from '@lucide/vue'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
connected: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
defineEmits<{
|
|
||||||
toggleMobileNav: []
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<header class="topbar">
|
|
||||||
<button class="mobile-menu" @click="$emit('toggleMobileNav')">
|
|
||||||
<Command :size="19" />
|
|
||||||
</button>
|
|
||||||
<div class="search">
|
|
||||||
<Search :size="16" />
|
|
||||||
<span>Search operations</span>
|
|
||||||
<kbd>⌘ K</kbd>
|
|
||||||
</div>
|
|
||||||
<div class="top-actions">
|
|
||||||
<span :class="['connection', connected ? 'live' : 'preview']">
|
|
||||||
<CircleDot :size="13" />
|
|
||||||
{{ connected ? 'Live' : 'Preview data' }}
|
|
||||||
</span>
|
|
||||||
<button class="ask"><Sparkles :size="15" /> Ask Iris</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.topbar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px 22px;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
background: rgba(8, 6, 20, 0.5);
|
|
||||||
backdrop-filter: blur(14px);
|
|
||||||
}
|
|
||||||
.mobile-menu { display: none; }
|
|
||||||
.search {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex: 1;
|
|
||||||
max-width: 560px;
|
|
||||||
padding: 8px 14px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 11px;
|
|
||||||
background: rgba(124, 108, 255, 0.06);
|
|
||||||
color: var(--tx-3);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
.search kbd {
|
|
||||||
margin-left: auto;
|
|
||||||
padding: 1px 4px;
|
|
||||||
border: 1px solid var(--line-2);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 8px;
|
|
||||||
color: var(--tx-3);
|
|
||||||
}
|
|
||||||
.top-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
.connection {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 4px 11px;
|
|
||||||
border-radius: 20px;
|
|
||||||
border: 1px solid var(--line-2);
|
|
||||||
background: rgba(124, 108, 255, 0.07);
|
|
||||||
color: var(--tx-2);
|
|
||||||
}
|
|
||||||
.connection.live { color: var(--st-work); }
|
|
||||||
.connection.preview { color: var(--st-queue); }
|
|
||||||
.ask {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--grad);
|
|
||||||
box-shadow: var(--glow-purple);
|
|
||||||
color: #fff;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: filter .16s;
|
|
||||||
}
|
|
||||||
.ask:hover { filter: brightness(1.08); }
|
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
|
||||||
.mobile-menu { display: flex; align-items: center; justify-content: center; padding: 6px; border: 1px solid var(--line-2); border-radius: 9px; background: transparent; color: var(--tx-2); cursor: pointer; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted } from 'vue'
|
|
||||||
import {
|
|
||||||
Activity, Bell, Bot, Boxes, Command, FileText,
|
|
||||||
LayoutDashboard, ListTodo, LogOut, MessageSquareText, Settings,
|
|
||||||
Shield, SlidersHorizontal, Sparkles, BookOpen,
|
|
||||||
AlertTriangle, Calendar,
|
|
||||||
} from '@lucide/vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useAuthStore } from '../../stores/auth'
|
|
||||||
import { useNotificationStore } from '../../stores/notifications'
|
|
||||||
import { initials } from '../../utils/format'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
activeView: string
|
|
||||||
mobileNavOpen: boolean
|
|
||||||
queuedTasks: number
|
|
||||||
incidents: number
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
navigate: [label: string]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const auth = useAuthStore()
|
|
||||||
const router = useRouter()
|
|
||||||
const notificationStore = useNotificationStore()
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
notificationStore.startPolling()
|
|
||||||
})
|
|
||||||
|
|
||||||
const ownerInitials = computed(() =>
|
|
||||||
auth.user?.displayName ? initials(auth.user.displayName) : 'OW'
|
|
||||||
)
|
|
||||||
|
|
||||||
const navigation = [
|
|
||||||
{ label: 'Dashboard', icon: LayoutDashboard },
|
|
||||||
{ label: 'Memory', icon: FileText },
|
|
||||||
{ label: 'Docs', icon: BookOpen },
|
|
||||||
{ label: 'Security', icon: Shield },
|
|
||||||
{ label: 'Projects', icon: Boxes },
|
|
||||||
{ label: 'Task Board', icon: ListTodo },
|
|
||||||
{ label: 'Incidents', icon: AlertTriangle },
|
|
||||||
{ separator: true },
|
|
||||||
{ label: 'Notifications', icon: Bell },
|
|
||||||
{ label: 'Calendar', icon: Calendar },
|
|
||||||
{ label: 'Agents', icon: Bot },
|
|
||||||
{ label: 'Models', icon: SlidersHorizontal },
|
|
||||||
{ label: 'Activity', icon: Activity },
|
|
||||||
{ label: 'Mobile Chat', icon: MessageSquareText },
|
|
||||||
]
|
|
||||||
|
|
||||||
function onNavigate(label: string) {
|
|
||||||
emit('navigate', label)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function logout() {
|
|
||||||
await auth.logout()
|
|
||||||
await router.replace('/login')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<aside :class="['sidebar', { open: mobileNavOpen }]">
|
|
||||||
<div class="brand">
|
|
||||||
<div class="brand-mark"><Command :size="18" /></div>
|
|
||||||
<div>
|
|
||||||
<strong>NEXUS</strong>
|
|
||||||
<span>Noveria Operations</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="nav">
|
|
||||||
<template v-for="item in navigation" :key="item.label ?? 'sep'">
|
|
||||||
<div v-if="item.separator" class="nav-separator"></div>
|
|
||||||
<button
|
|
||||||
v-else
|
|
||||||
:class="{ active: activeView === item.label }"
|
|
||||||
@click="onNavigate(item.label)"
|
|
||||||
>
|
|
||||||
<component :is="item.icon" :size="17" />
|
|
||||||
<span>{{ item.label }}</span>
|
|
||||||
<i v-if="item.label === 'Task Board'">{{ queuedTasks }}</i>
|
|
||||||
<i v-if="item.label === 'Incidents'">{{ incidents }}</i>
|
|
||||||
<i v-if="item.label === 'Notifications' && notificationStore.unreadCount > 0" class="badge-red">{{ notificationStore.unreadCount }}</i>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="sidebar-bottom">
|
|
||||||
<button :class="{ active: activeView === 'Settings' }" @click="onNavigate('Settings')"><Settings :size="17" /> Settings</button>
|
|
||||||
<button class="owner" type="button" title="Sign out" @click="logout">
|
|
||||||
<div class="avatar">{{ ownerInitials }}</div>
|
|
||||||
<div><strong>{{ auth.user?.displayName ?? 'Owner' }}</strong><span>{{ auth.user?.role ?? 'owner' }}</span></div>
|
|
||||||
<LogOut :size="15" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.sidebar {
|
|
||||||
width: 210px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: linear-gradient(180deg, rgba(14, 12, 32, 0.92), rgba(8, 6, 20, 0.92));
|
|
||||||
border-right: 1px solid var(--line);
|
|
||||||
backdrop-filter: blur(14px);
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 0 8px;
|
|
||||||
}
|
|
||||||
.brand {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 16px 10px 12px;
|
|
||||||
}
|
|
||||||
.brand-mark {
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
border-radius: 9px;
|
|
||||||
background: var(--grad);
|
|
||||||
box-shadow: var(--glow-purple);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.brand div strong { display: block; font-family: 'Space Grotesk', sans-serif; font-size: 10px; letter-spacing: .12em; }
|
|
||||||
.brand div span { font-size: 8px; color: var(--tx-3); }
|
|
||||||
.nav {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1px;
|
|
||||||
padding: 4px 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.nav button {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 9px;
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--tx-2);
|
|
||||||
font-size: 10.5px;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background .15s, color .15s;
|
|
||||||
}
|
|
||||||
.nav button:hover { background: rgba(124,108,255,.08); color: var(--tx); }
|
|
||||||
.nav button.active {
|
|
||||||
background: linear-gradient(90deg, rgba(124,108,255,.22), rgba(124,108,255,.04));
|
|
||||||
box-shadow: inset 0 0 0 1px rgba(124,108,255,.25);
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.nav button i {
|
|
||||||
margin-left: auto;
|
|
||||||
background: rgba(124,108,255,.14);
|
|
||||||
border: 1px solid var(--line-2);
|
|
||||||
color: var(--tx);
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-style: normal;
|
|
||||||
font-size: 8px;
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: 20px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
.nav button i.badge-red {
|
|
||||||
background: rgba(251,113,133,.18);
|
|
||||||
border-color: rgba(251,113,133,.4);
|
|
||||||
color: var(--st-block);
|
|
||||||
}
|
|
||||||
.nav-separator {
|
|
||||||
height: 1px;
|
|
||||||
margin: 6px 10px;
|
|
||||||
background: var(--line);
|
|
||||||
}
|
|
||||||
.sidebar-bottom { padding: 8px 0; border-top: 1px solid var(--line); }
|
|
||||||
.sidebar-bottom > button {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 9px;
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--tx-2);
|
|
||||||
font-size: 10.5px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background .15s, color .15s;
|
|
||||||
}
|
|
||||||
.sidebar-bottom > button:hover { background: rgba(124,108,255,.08); color: var(--tx); }
|
|
||||||
.sidebar-bottom > button.active {
|
|
||||||
background: linear-gradient(90deg, rgba(124,108,255,.22), rgba(124,108,255,.04));
|
|
||||||
box-shadow: inset 0 0 0 1px rgba(124,108,255,.25);
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.owner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 6px;
|
|
||||||
}
|
|
||||||
.owner div strong { display: block; font-size: 9px; color: var(--tx); }
|
|
||||||
.owner div span { font-size: 7.5px; color: var(--tx-3); text-transform: capitalize; }
|
|
||||||
.owner > svg:last-child { margin-left: auto; opacity: .4; transition: opacity .15s; }
|
|
||||||
.owner:hover > svg:last-child { opacity: 1; }
|
|
||||||
.avatar {
|
|
||||||
width: 26px;
|
|
||||||
height: 26px;
|
|
||||||
border-radius: 8px;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
background: var(--grad-soft);
|
|
||||||
border: 1px solid var(--line-2);
|
|
||||||
color: var(--tx);
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
|
||||||
.sidebar { position: fixed; inset: 0; z-index: 100; transform: translateX(-100%); transition: transform .25s; }
|
|
||||||
.sidebar.open { transform: translateX(0); }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { NavItemDef } from '../../composables/icons'
|
|
||||||
import NavItem from './NavItem.vue'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
label: string
|
|
||||||
items: NavItemDef[]
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="nav-group">
|
|
||||||
<div class="nav-group-label">{{ label }}</div>
|
|
||||||
<NavItem
|
|
||||||
v-for="item in items"
|
|
||||||
:key="item.label"
|
|
||||||
:icon="item.icon"
|
|
||||||
:label="item.label"
|
|
||||||
:route="item.route"
|
|
||||||
:count="item.count"
|
|
||||||
:active="item.active"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.nav-group-label {
|
|
||||||
font-size: 10px;
|
|
||||||
letter-spacing: .18em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--tx-3);
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 16px 10px 7px;
|
|
||||||
font-family: 'Manrope', sans-serif;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
|
||||||
import { icons } from '../../composables/icons'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
icon: string
|
|
||||||
label: string
|
|
||||||
route?: string
|
|
||||||
count?: string
|
|
||||||
active?: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
const isActive = computed(() => {
|
|
||||||
if (props.active) return true
|
|
||||||
if (props.route && route.path === props.route) return true
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
function navigate() {
|
|
||||||
if (props.route) {
|
|
||||||
router.push(props.route)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<button
|
|
||||||
:class="['nav-item', { active: isActive }]"
|
|
||||||
@click="navigate"
|
|
||||||
>
|
|
||||||
<!-- Icon -->
|
|
||||||
<span class="nav-icon" v-html="icons[icon] || ''"></span>
|
|
||||||
|
|
||||||
<!-- Label -->
|
|
||||||
<span class="nav-label">{{ label }}</span>
|
|
||||||
|
|
||||||
<!-- Count badge -->
|
|
||||||
<span v-if="count !== undefined" class="count">{{ count }}</span>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.nav-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 11px;
|
|
||||||
padding: 9px 11px;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--tx-2);
|
|
||||||
font-family: 'Manrope', sans-serif;
|
|
||||||
font-size: 13.5px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
position: relative;
|
|
||||||
transition: background .16s, color .16s;
|
|
||||||
text-decoration: none;
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item:hover {
|
|
||||||
background: rgba(124,108,255,.08);
|
|
||||||
color: var(--tx);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item.active {
|
|
||||||
color: #fff;
|
|
||||||
background: linear-gradient(90deg, rgba(124,108,255,.22), rgba(124,108,255,.04));
|
|
||||||
box-shadow: inset 0 0 0 1px rgba(124,108,255,.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item.active::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: -12px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
width: 3px;
|
|
||||||
height: 20px;
|
|
||||||
border-radius: 3px;
|
|
||||||
background: var(--grad);
|
|
||||||
box-shadow: var(--glow-purple);
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-icon {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 17px;
|
|
||||||
height: 17px;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
opacity: .85;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-icon :deep(svg) {
|
|
||||||
width: 17px;
|
|
||||||
height: 17px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-label {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.count {
|
|
||||||
margin-left: auto;
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 1px 8px;
|
|
||||||
border-radius: 20px;
|
|
||||||
background: rgba(124,108,255,.16);
|
|
||||||
color: var(--tx);
|
|
||||||
line-height: 1.4;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,139 +1,180 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Sidebar — kompakte Icon-Rail (V2-Shell, alle Seiten)
|
||||||
|
*
|
||||||
|
* Collapsed 68px, expandiert bei Hover auf 232px als Overlay
|
||||||
|
* (kein Layout-Shift im Content). Ersetzt Sidebar + Topbar.
|
||||||
|
* Mobile: als Drawer über mobileOpen/close.
|
||||||
|
*/
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
import { useAgentStore } from '../../stores/agents'
|
import { useNotificationStore } from '../../stores/notifications'
|
||||||
import { useTaskStore } from '../../stores/tasks'
|
import { useLiveSyncStore } from '../../stores/liveSync'
|
||||||
import { navigation, icons } from '../../composables/icons'
|
import { railNav, railFooterNav, svg } from '../../composables/icons'
|
||||||
import type { NavGroupDef } from '../../composables/icons'
|
import { initials } from '../../utils/format'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
mobileOpen?: boolean
|
mobileOpen?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
const emit = defineEmits<{
|
||||||
close: []
|
close: []
|
||||||
}>()
|
}>()
|
||||||
import NavGroup from './NavGroup.vue'
|
|
||||||
import { initials } from '../../utils/format'
|
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const agentStore = useAgentStore()
|
const notificationStore = useNotificationStore()
|
||||||
const taskStore = useTaskStore()
|
const liveSync = useLiveSyncStore()
|
||||||
|
|
||||||
const ownerInitials = computed(() =>
|
const ownerInitials = computed(() =>
|
||||||
auth.user?.displayName ? initials(auth.user.displayName) : 'OW'
|
auth.user?.displayName ? initials(auth.user.displayName) : 'OW'
|
||||||
)
|
)
|
||||||
|
|
||||||
function logout() {
|
function isActive(itemRoute?: string): boolean {
|
||||||
auth.logout()
|
if (!itemRoute) return false
|
||||||
router.replace('/login')
|
if (route.path === itemRoute) return true
|
||||||
|
// Detailrouten (/tasks/:id, /agents/:id) markieren den Hauptpunkt
|
||||||
|
return route.path.startsWith(itemRoute + '/')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function navigate(itemRoute?: string) {
|
||||||
* Dynamische Nav-Item-Counts aus den Stores.
|
if (!itemRoute) return
|
||||||
* Überschreibt die hartcodierten `count`-Werte im navigation-Array.
|
emit('close')
|
||||||
*/
|
router.push(itemRoute)
|
||||||
const dynamicNavigation = computed<NavGroupDef[]>(() => {
|
}
|
||||||
// Deep-clone: Jede Gruppe und jedes Item neu erstellen
|
|
||||||
return navigation.map(group => ({
|
|
||||||
...group,
|
|
||||||
items: group.items.map(item => {
|
|
||||||
let dynamicCount: string | undefined
|
|
||||||
|
|
||||||
switch (item.label) {
|
async function logout() {
|
||||||
case 'Agenten':
|
await auth.logout()
|
||||||
case 'Hosts · OpenClaw':
|
await router.replace('/login')
|
||||||
dynamicCount = String(agentStore.agentList.length)
|
}
|
||||||
break
|
|
||||||
case 'Task Board':
|
|
||||||
dynamicCount = String(taskStore.taskList.length)
|
|
||||||
break
|
|
||||||
case 'Kosten & Tokens':
|
|
||||||
dynamicCount = agentStore.todayCost
|
|
||||||
break
|
|
||||||
case 'Docs & .md':
|
|
||||||
dynamicCount = '0'
|
|
||||||
break
|
|
||||||
case 'Incidents':
|
|
||||||
dynamicCount = '0'
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
const statusLabel = computed(() => {
|
||||||
...item,
|
if (liveSync.connected) return 'Live'
|
||||||
count: dynamicCount ?? item.count,
|
if (liveSync.connecting) return 'Verbinde…'
|
||||||
}
|
return 'Polling'
|
||||||
}),
|
|
||||||
}))
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside :class="['sidebar', { open: mobileOpen }]">
|
<aside :class="['rail', { open: mobileOpen }]">
|
||||||
<button class="sidebar-close" @click="$emit('close')" v-html="icons.chevron_left || ''"></button>
|
|
||||||
<!-- Brand -->
|
<!-- Brand -->
|
||||||
<div class="side-top">
|
<button class="rail-brand" @click="navigate('/dashboard')">
|
||||||
<div class="brand-mark" v-html="icons.command || ''"></div>
|
<span class="brand-mark" v-html="svg('command')"></span>
|
||||||
<div>
|
<span class="rail-label brand-label">NEXUS</span>
|
||||||
<div class="brand-name">NEXUS</div>
|
</button>
|
||||||
<div class="brand-sub">Mission Control</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
<nav class="nav">
|
<nav class="rail-nav v2-scroll">
|
||||||
<NavGroup
|
<button
|
||||||
v-for="(group, idx) in dynamicNavigation"
|
v-for="item in railNav"
|
||||||
:key="idx"
|
:key="item.route"
|
||||||
:label="group.group"
|
:class="['rail-item', { active: isActive(item.route) }]"
|
||||||
:items="group.items"
|
:title="item.label"
|
||||||
/>
|
@click="navigate(item.route)"
|
||||||
|
>
|
||||||
|
<span class="rail-icon" v-html="svg(item.icon)"></span>
|
||||||
|
<span
|
||||||
|
v-if="item.route === '/notifications' && notificationStore.unreadCount > 0"
|
||||||
|
class="rail-dot"
|
||||||
|
></span>
|
||||||
|
<span class="rail-label">{{ item.label }}</span>
|
||||||
|
<span
|
||||||
|
v-if="item.route === '/notifications' && notificationStore.unreadCount > 0"
|
||||||
|
class="rail-count"
|
||||||
|
>{{ notificationStore.unreadCount }}</span>
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="side-foot">
|
<div class="rail-foot">
|
||||||
<div class="avatar">{{ ownerInitials }}</div>
|
<div class="rail-item static" :title="statusLabel">
|
||||||
<div class="owner-info">
|
<span class="rail-icon">
|
||||||
<div class="owner-name">{{ auth.user?.displayName ?? 'Owner' }}</div>
|
<span :class="['status-dot', liveSync.connected ? 'on' : 'off']"></span>
|
||||||
<div class="owner-role">{{ auth.user?.role ?? 'Owner' }}</div>
|
</span>
|
||||||
|
<span class="rail-label dim">{{ statusLabel }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-for="item in railFooterNav"
|
||||||
|
:key="item.route"
|
||||||
|
:class="['rail-item', { active: isActive(item.route) }]"
|
||||||
|
:title="item.label"
|
||||||
|
@click="navigate(item.route)"
|
||||||
|
>
|
||||||
|
<span class="rail-icon" v-html="svg(item.icon)"></span>
|
||||||
|
<span class="rail-label">{{ item.label }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="rail-owner">
|
||||||
|
<span class="avatar">{{ ownerInitials }}</span>
|
||||||
|
<span class="rail-label owner-label">
|
||||||
|
<span class="owner-name">{{ auth.user?.displayName ?? 'Owner' }}</span>
|
||||||
|
<span class="owner-role">{{ auth.user?.role ?? 'Owner' }}</span>
|
||||||
|
</span>
|
||||||
|
<button class="logout-btn rail-label" title="Abmelden" @click="logout" v-html="svg('logout')"></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.sidebar {
|
.rail {
|
||||||
width: 248px;
|
position: absolute;
|
||||||
flex: 0 0 248px;
|
inset: 0 auto 0 0;
|
||||||
height: 100vh;
|
width: 68px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: linear-gradient(180deg, rgba(14,12,32,.92), rgba(8,6,20,.92));
|
background: linear-gradient(180deg, rgba(14, 12, 32, 0.92), rgba(8, 6, 20, 0.92));
|
||||||
border-right: 1px solid var(--line);
|
border-right: 1px solid var(--line);
|
||||||
backdrop-filter: blur(14px);
|
backdrop-filter: blur(14px);
|
||||||
padding: 0;
|
overflow: hidden;
|
||||||
position: relative;
|
transition: width .18s ease, box-shadow .18s ease;
|
||||||
z-index: 2;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-top {
|
.rail:hover {
|
||||||
|
width: 232px;
|
||||||
|
box-shadow: 24px 0 60px -30px rgba(0, 0, 0, .8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Labels: unsichtbar bis die Rail expandiert */
|
||||||
|
.rail-label {
|
||||||
|
opacity: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: opacity .14s ease .04s;
|
||||||
|
font-size: 13px;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail:hover .rail-label,
|
||||||
|
.rail.open .rail-label {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Brand ── */
|
||||||
|
.rail-brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 11px;
|
gap: 13px;
|
||||||
padding: 18px 18px 16px;
|
padding: 15px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-mark {
|
.brand-mark {
|
||||||
width: 38px;
|
width: 38px;
|
||||||
height: 38px;
|
height: 38px;
|
||||||
|
flex: 0 0 38px;
|
||||||
border-radius: 11px;
|
border-radius: 11px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
background: var(--grad);
|
background: var(--grad);
|
||||||
box-shadow: var(--glow-purple);
|
box-shadow: var(--glow-purple);
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-mark :deep(svg) {
|
.brand-mark :deep(svg) {
|
||||||
@@ -142,108 +183,154 @@ const dynamicNavigation = computed<NavGroupDef[]>(() => {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-name {
|
.brand-label {
|
||||||
font-family: 'Space Grotesk', sans-serif;
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 17px;
|
font-size: 16px;
|
||||||
letter-spacing: .14em;
|
letter-spacing: .14em;
|
||||||
line-height: 1;
|
color: var(--tx);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-sub {
|
/* ── Nav ── */
|
||||||
font-size: 10.5px;
|
.rail-nav {
|
||||||
color: var(--tx-3);
|
|
||||||
letter-spacing: .05em;
|
|
||||||
margin-top: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav {
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
|
||||||
padding: 6px 12px 12px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 3px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-foot {
|
.rail-item {
|
||||||
padding: 12px;
|
position: relative;
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 13px;
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 13px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: none;
|
||||||
|
border-radius: 11px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--tx-2);
|
||||||
|
font-family: 'Manrope', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background .15s;
|
transition: background .15s, color .15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-foot:hover {
|
.rail-item:not(.static):hover {
|
||||||
background: rgba(124,108,255,.06);
|
background: rgba(124, 108, 255, .08);
|
||||||
|
color: var(--tx);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-close {
|
.rail-item.active {
|
||||||
display: none;
|
color: #fff;
|
||||||
|
background: linear-gradient(90deg, rgba(124, 108, 255, .22), rgba(124, 108, 255, .04));
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(124, 108, 255, .25);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
.rail-item.static {
|
||||||
.sidebar {
|
cursor: default;
|
||||||
position: fixed;
|
}
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
z-index: 100;
|
|
||||||
height: 100vh;
|
|
||||||
width: 280px;
|
|
||||||
transform: translateX(-100%);
|
|
||||||
transition: transform 0.25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar.open {
|
.rail-icon {
|
||||||
transform: translateX(0);
|
width: 18px;
|
||||||
}
|
height: 18px;
|
||||||
|
flex: 0 0 18px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
opacity: .9;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-close {
|
.rail-icon :deep(svg) {
|
||||||
display: flex;
|
width: 18px;
|
||||||
align-items: center;
|
height: 18px;
|
||||||
justify-content: center;
|
}
|
||||||
position: absolute;
|
|
||||||
top: 18px;
|
|
||||||
right: 12px;
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--tx-2);
|
|
||||||
cursor: pointer;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-close:hover {
|
.rail-item .rail-label {
|
||||||
background: rgba(124,108,255,.1);
|
flex: 1;
|
||||||
color: var(--tx);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-close :deep(svg) {
|
/* Ungelesen-Punkt am Icon (collapsed sichtbar) */
|
||||||
width: 18px;
|
.rail-dot {
|
||||||
height: 18px;
|
position: absolute;
|
||||||
}
|
left: 24px;
|
||||||
|
top: 9px;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--st-block);
|
||||||
|
box-shadow: 0 0 8px rgba(251, 113, 133, .8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-count {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(251, 113, 133, .16);
|
||||||
|
border: 1px solid rgba(251, 113, 133, .35);
|
||||||
|
color: var(--st-block);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Footer ── */
|
||||||
|
.rail-foot {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 6px 12px 10px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.on {
|
||||||
|
background: var(--st-work);
|
||||||
|
animation: pulse-work 1.8s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.off {
|
||||||
|
background: var(--st-idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-label.dim {
|
||||||
|
color: var(--tx-3);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail-owner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 13px;
|
||||||
|
padding: 7px 5px 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
width: 34px;
|
width: 34px;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
|
flex: 0 0 34px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: var(--grad-soft);
|
background: var(--grad-soft);
|
||||||
border: 1px solid var(--line-2);
|
border: 1px solid var(--line-2);
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
color: var(--tx);
|
color: var(--tx);
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.owner-info {
|
.owner-label {
|
||||||
min-width: 0;
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.owner-name {
|
.owner-name {
|
||||||
@@ -258,7 +345,41 @@ const dynamicNavigation = computed<NavGroupDef[]>(() => {
|
|||||||
.owner-role {
|
.owner-role {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--tx-3);
|
color: var(--tx-3);
|
||||||
margin-top: 1px;
|
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logout-btn {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--tx-3);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn:hover {
|
||||||
|
color: var(--st-block);
|
||||||
|
background: rgba(251, 113, 133, .1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn :deep(svg) {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile: Drawer ── */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.rail {
|
||||||
|
position: fixed;
|
||||||
|
width: 232px;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform .22s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,210 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { icons } from '../../composables/icons'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
connected?: boolean
|
|
||||||
statusLabel?: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
defineEmits<{
|
|
||||||
'toggle-sidebar': []
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<header class="topbar">
|
|
||||||
<!-- Hamburger (mobile only) -->
|
|
||||||
<button class="hamburger" @click="$emit('toggle-sidebar')" v-html="icons.list || ''"></button>
|
|
||||||
|
|
||||||
<!-- Search -->
|
|
||||||
<div class="search">
|
|
||||||
<span class="search-icon" v-html="icons.search || ''"></span>
|
|
||||||
<span class="search-placeholder">Operationen, Agents oder Tasks suchen…</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Spacer -->
|
|
||||||
<div class="spacer"></div>
|
|
||||||
|
|
||||||
<!-- Status Pill -->
|
|
||||||
<span :class="['pill', connected ? 'live' : 'preview']">
|
|
||||||
<span class="status-dot" :class="connected ? 'on' : 'off'"></span>
|
|
||||||
{{ connected ? (statusLabel || 'OpenClaw verbunden') : 'Preview' }}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- Ask Iris Button -->
|
|
||||||
<button class="btn btn-primary ask-iris-btn">
|
|
||||||
<span class="btn-icon" v-html="icons.spark || ''"></span>
|
|
||||||
<span class="ask-label">Ask Iris</span>
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.topbar {
|
|
||||||
height: 62px;
|
|
||||||
flex: 0 0 62px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
padding: 0 22px;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
background: rgba(8,6,20,.5);
|
|
||||||
backdrop-filter: blur(14px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search {
|
|
||||||
flex: 1;
|
|
||||||
max-width: 560px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
height: 38px;
|
|
||||||
padding: 0 14px;
|
|
||||||
border-radius: 11px;
|
|
||||||
background: rgba(124,108,255,.06);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
color: var(--tx-3);
|
|
||||||
font-size: 13.5px;
|
|
||||||
font-family: 'Manrope', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-icon :deep(svg) {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-placeholder {
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spacer {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
height: 28px;
|
|
||||||
padding: 0 11px;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 11.5px;
|
|
||||||
font-weight: 600;
|
|
||||||
font-family: 'Manrope', sans-serif;
|
|
||||||
border: 1px solid var(--line-2);
|
|
||||||
background: rgba(124,108,255,.07);
|
|
||||||
color: var(--tx-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot.on {
|
|
||||||
background: var(--st-work);
|
|
||||||
box-shadow: 0 0 0 0 rgba(61,220,151,.5);
|
|
||||||
animation: pulse-work 1.8s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-dot.off {
|
|
||||||
background: var(--st-idle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
height: 36px;
|
|
||||||
padding: 0 14px;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-family: 'Manrope', sans-serif;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
transition: filter .16s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: var(--grad);
|
|
||||||
color: #fff;
|
|
||||||
box-shadow: var(--glow-purple);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover {
|
|
||||||
filter: brightness(1.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon :deep(svg) {
|
|
||||||
width: 15px;
|
|
||||||
height: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hamburger {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.topbar {
|
|
||||||
padding: 0 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search {
|
|
||||||
flex: 1;
|
|
||||||
max-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hamburger {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 34px;
|
|
||||||
height: 34px;
|
|
||||||
border-radius: 9px;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--tx-2);
|
|
||||||
cursor: pointer;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hamburger:hover {
|
|
||||||
background: rgba(124,108,255,.1);
|
|
||||||
color: var(--tx);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hamburger :deep(svg) {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ask-iris-btn {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
border-radius: 9px;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ask-label {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ask-iris-btn .btn-icon {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
|
||||||
@@ -26,6 +26,9 @@ export const icons: Record<string, string> = {
|
|||||||
plus: `<path d="M12 5v14M5 12h14"/>`,
|
plus: `<path d="M12 5v14M5 12h14"/>`,
|
||||||
command: `<path d="M7 4a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V7a3 3 0 0 0-3-3H7z"/><path d="M12 8v8M8 12h8"/>`,
|
command: `<path d="M7 4a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V7a3 3 0 0 0-3-3H7z"/><path d="M12 8v8M8 12h8"/>`,
|
||||||
gear: `<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1.03 1.56V21a2 2 0 1 1-4 0v-.09a1.7 1.7 0 0 0-1.11-1.56 1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.7 1.7 0 0 0 .34-1.87 1.7 1.7 0 0 0-1.56-1.03H3a2 2 0 1 1 0-4h.09a1.7 1.7 0 0 0 1.56-1.11 1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.87.34h.09a1.7 1.7 0 0 0 1.03-1.56V3a2 2 0 1 1 4 0v.09a1.7 1.7 0 0 0 1.03 1.56 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.87v.09a1.7 1.7 0 0 0 1.56 1.03H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.51 1.87Z"/>`,
|
gear: `<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1.03 1.56V21a2 2 0 1 1-4 0v-.09a1.7 1.7 0 0 0-1.11-1.56 1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.7 1.7 0 0 0 .34-1.87 1.7 1.7 0 0 0-1.56-1.03H3a2 2 0 1 1 0-4h.09a1.7 1.7 0 0 0 1.56-1.11 1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.87.34h.09a1.7 1.7 0 0 0 1.03-1.56V3a2 2 0 1 1 4 0v.09a1.7 1.7 0 0 0 1.03 1.56 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.87v.09a1.7 1.7 0 0 0 1.56 1.03H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.51 1.87Z"/>`,
|
||||||
|
bell: `<path d="M18 9a6 6 0 1 0-12 0c0 6-2.5 7-2.5 7h17S18 15 18 9M10.3 20a2 2 0 0 0 3.4 0"/>`,
|
||||||
|
calendar: `<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M8 3v4M16 3v4M3 10h18"/>`,
|
||||||
|
logout: `<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>`,
|
||||||
chevron_left: `<path d="m15 18-6-6 6-6"/>`,
|
chevron_left: `<path d="m15 18-6-6 6-6"/>`,
|
||||||
chevron_right: `<path d="m9 18 6-6-6-6"/>`,
|
chevron_right: `<path d="m9 18 6-6-6-6"/>`,
|
||||||
dots: `<circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/><circle cx="5" cy="12" r="1.5"/>`,
|
dots: `<circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/><circle cx="5" cy="12" r="1.5"/>`,
|
||||||
@@ -51,46 +54,22 @@ export interface NavGroupDef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigation structure matching NEXUS.nav from agents.js
|
* Rail-Navigation — EINE Quelle für alle Seiten (Dashboard + Rest).
|
||||||
|
* Flache Liste, nur Routen die real existieren; Settings sitzt in der Rail
|
||||||
|
* unten im Fußbereich (railFooterNav).
|
||||||
*/
|
*/
|
||||||
export const navigation: NavGroupDef[] = [
|
export const railNav: NavItemDef[] = [
|
||||||
{
|
{ icon: 'grid', label: 'Dashboard', route: '/dashboard' },
|
||||||
group: 'Operations',
|
{ icon: 'cpu', label: 'Agenten', route: '/agents' },
|
||||||
items: [
|
{ icon: 'list', label: 'Task Board', route: '/tasks' },
|
||||||
{ icon: 'grid', label: 'Dashboard', route: '/dashboard', active: true },
|
{ icon: 'brain', label: 'Memory', route: '/memory' },
|
||||||
{ icon: 'cpu', label: 'Agenten', route: '/agents' },
|
{ icon: 'doc', label: 'Docs', route: '/docs' },
|
||||||
{ icon: 'list', label: 'Task Board', route: '/tasks' },
|
{ icon: 'calendar', label: 'Kalender', route: '/calendar' },
|
||||||
{ icon: 'flow', label: 'Orchestrierung', route: '/orchestration' },
|
{ icon: 'bell', label: 'Benachrichtigungen', route: '/notifications' },
|
||||||
],
|
{ icon: 'alert', label: 'Incidents', route: '/incidents' },
|
||||||
},
|
{ icon: 'shield', label: 'Security', route: '/security' },
|
||||||
{
|
]
|
||||||
group: 'Knowledge',
|
|
||||||
items: [
|
export const railFooterNav: NavItemDef[] = [
|
||||||
{ icon: 'brain', label: 'Memory', route: '/memory' },
|
{ icon: 'gear', label: 'Einstellungen', route: '/settings' },
|
||||||
{ icon: 'doc', label: 'Docs & .md', route: '/docs' },
|
|
||||||
{ icon: 'search', label: 'Research', route: '/research' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: 'Infrastructure',
|
|
||||||
items: [
|
|
||||||
{ icon: 'server', label: 'Hosts · OpenClaw', route: '/hosts' },
|
|
||||||
{ icon: 'model', label: 'Modelle', route: '/models' },
|
|
||||||
{ icon: 'activity', label: 'Activity Log', route: '/activity' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: 'Governance',
|
|
||||||
items: [
|
|
||||||
{ icon: 'coin', label: 'Kosten & Tokens', route: '/costs' },
|
|
||||||
{ icon: 'shield', label: 'Security', route: '/security' },
|
|
||||||
{ icon: 'alert', label: 'Incidents', route: '/incidents' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
group: 'System',
|
|
||||||
items: [
|
|
||||||
{ icon: 'gear', label: 'Settings', route: '/settings' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,49 +1,79 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/**
|
/**
|
||||||
* NexusLayout — V2 Dashboard Shell
|
* NexusLayout — gemeinsame Shell für ALLE Seiten
|
||||||
* Flex row, 100vh, overflow hidden.
|
*
|
||||||
* Sidebar (248px) + Main (flex:1, flex-column)
|
* Icon-Rail links (68px, Hover-Expand als Overlay), keine Topbar.
|
||||||
* Mobile: Sidebar als Overlay mit Hamburger-Toggle
|
* Content bekommt die volle restliche Fläche:
|
||||||
|
* - Routen mit meta.fullBleed (Dashboard): overflow hidden, eigene Höhenlogik
|
||||||
|
* - alle anderen: scrollbarer Container mit Seiten-Padding
|
||||||
|
*
|
||||||
|
* Die Live-Verbindung (SSE) gehört der Shell — EINE Verbindung für die
|
||||||
|
* ganze App statt connect/disconnect bei jedem Seitenwechsel.
|
||||||
*/
|
*/
|
||||||
import { ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { RouterView } from 'vue-router'
|
import { RouterView, useRoute } from 'vue-router'
|
||||||
import { useDashboardStore } from '../stores/dashboard'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
import { useLiveSyncStore } from '../stores/liveSync'
|
||||||
|
import { useNotificationStore } from '../stores/notifications'
|
||||||
import GalaxyBackground from '../components/background/GalaxyBackground.vue'
|
import GalaxyBackground from '../components/background/GalaxyBackground.vue'
|
||||||
import Sidebar from '../components/layout/Sidebar.vue'
|
import Sidebar from '../components/layout/Sidebar.vue'
|
||||||
import Topbar from '../components/layout/Topbar.vue'
|
import { svg } from '../composables/icons'
|
||||||
|
|
||||||
const dashboardStore = useDashboardStore()
|
const route = useRoute()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const liveSync = useLiveSyncStore()
|
||||||
|
const notificationStore = useNotificationStore()
|
||||||
|
|
||||||
/* ── Mobile Sidebar State ───────────────────────── */
|
const isFullBleed = computed(() => Boolean(route.meta.fullBleed))
|
||||||
|
|
||||||
|
/* ── Mobile Drawer ─────────────────────────────── */
|
||||||
const mobileMenuOpen = ref(false)
|
const mobileMenuOpen = ref(false)
|
||||||
|
|
||||||
function closeMobileMenu() {
|
function closeMobileMenu() {
|
||||||
mobileMenuOpen.value = false
|
mobileMenuOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Live-Verbindung (app-weit, genau eine) ─────── */
|
||||||
|
const liveUser = computed(() => (auth.isIris ? 'iris' : 'bao'))
|
||||||
|
|
||||||
|
function onVisibilityChange() {
|
||||||
|
if (document.visibilityState === 'visible' && !liveSync.connected && !liveSync.connecting) {
|
||||||
|
liveSync.connect(liveUser.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOnline() {
|
||||||
|
liveSync.reconnectNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
liveSync.connect(liveUser.value)
|
||||||
|
notificationStore.startPolling()
|
||||||
|
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||||
|
window.addEventListener('online', onOnline)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
liveSync.disconnect()
|
||||||
|
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||||
|
window.removeEventListener('online', onOnline)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="nexus-layout">
|
<div class="nexus-layout">
|
||||||
<GalaxyBackground />
|
<GalaxyBackground />
|
||||||
<Sidebar
|
|
||||||
:mobile-open="mobileMenuOpen"
|
|
||||||
@close="closeMobileMenu"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Mobile Backdrop -->
|
<div class="rail-slot">
|
||||||
<div
|
<Sidebar :mobile-open="mobileMenuOpen" @close="closeMobileMenu" />
|
||||||
v-if="mobileMenuOpen"
|
</div>
|
||||||
class="mobile-backdrop"
|
|
||||||
@click="closeMobileMenu"
|
<!-- Mobile: Hamburger + Backdrop -->
|
||||||
></div>
|
<button class="mobile-toggle" @click="mobileMenuOpen = !mobileMenuOpen" v-html="svg('list')"></button>
|
||||||
|
<div v-if="mobileMenuOpen" class="mobile-backdrop" @click="closeMobileMenu"></div>
|
||||||
|
|
||||||
<main class="nexus-main">
|
<main class="nexus-main">
|
||||||
<Topbar
|
<div :class="['nexus-content', isFullBleed ? 'full-bleed' : 'page-scroll v2-scroll']">
|
||||||
:connected="dashboardStore.isGatewayConnected"
|
|
||||||
:status-label="dashboardStore.irisStatusLabel"
|
|
||||||
@toggle-sidebar="mobileMenuOpen = !mobileMenuOpen"
|
|
||||||
/>
|
|
||||||
<div class="nexus-content">
|
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -59,6 +89,15 @@ function closeMobileMenu() {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Platzhalter in der Flex-Reihe — die Rail selbst liegt absolut darüber
|
||||||
|
und kann expandieren, ohne den Content zu verschieben. */
|
||||||
|
.rail-slot {
|
||||||
|
width: 68px;
|
||||||
|
flex: 0 0 68px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
.nexus-main {
|
.nexus-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -70,19 +109,62 @@ function closeMobileMenu() {
|
|||||||
|
|
||||||
.nexus-content {
|
.nexus-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nexus-content.full-bleed {
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nexus-content.full-bleed > :deep(*) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nexus-content.page-scroll {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px 28px 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-toggle,
|
||||||
.mobile-backdrop {
|
.mobile-backdrop {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
|
.rail-slot {
|
||||||
|
width: 0;
|
||||||
|
flex: 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
.nexus-main {
|
.nexus-main {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-toggle {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
position: fixed;
|
||||||
|
top: 12px;
|
||||||
|
left: 12px;
|
||||||
|
z-index: 90;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 1px solid var(--line-2);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--glass);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
color: var(--tx-2);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-toggle :deep(svg) {
|
||||||
|
width: 19px;
|
||||||
|
height: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
.mobile-backdrop {
|
.mobile-backdrop {
|
||||||
display: block;
|
display: block;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -90,5 +172,9 @@ function closeMobileMenu() {
|
|||||||
z-index: 99;
|
z-index: 99;
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nexus-content.page-scroll {
|
||||||
|
padding: 60px 16px 48px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+15
-19
@@ -19,31 +19,27 @@ const routes = [
|
|||||||
{ path: '/login', name: 'Login', component: LoginView, meta: { public: true } },
|
{ path: '/login', name: 'Login', component: LoginView, meta: { public: true } },
|
||||||
{ path: '/', redirect: '/dashboard' },
|
{ path: '/', redirect: '/dashboard' },
|
||||||
|
|
||||||
// V2 Dashboard (neues NexusLayout + FlowBoard)
|
// Eine Shell für alle Seiten (Rail-Navigation, app-weiter Live-Sync)
|
||||||
{
|
{
|
||||||
path: '/dashboard',
|
path: '/',
|
||||||
component: NexusLayout,
|
component: NexusLayout,
|
||||||
children: [
|
children: [
|
||||||
{ path: '', name: 'Dashboard', component: FlowBoard },
|
{ path: 'dashboard', name: 'Dashboard', component: FlowBoard, meta: { fullBleed: true } },
|
||||||
|
{ path: 'agents', name: 'Agents', component: AgentsIndexView },
|
||||||
|
{ path: 'agents/:id', name: 'AgentDetail', component: AgentDetailView },
|
||||||
|
{ path: 'tasks', name: 'Task Board', component: TaskBoardView },
|
||||||
|
{ path: 'tasks/:id', name: 'TaskDetail', component: TaskDetailView },
|
||||||
|
{ path: 'memory', name: 'Memory', component: MemoryView },
|
||||||
|
{ path: 'docs', name: 'Docs', component: DocsView },
|
||||||
|
{ path: 'calendar', name: 'Calendar', component: CalendarView },
|
||||||
|
{ path: 'notifications', name: 'Notifications', component: NotificationsView },
|
||||||
|
{ path: 'incidents', name: 'Incidents', component: IncidentsView },
|
||||||
|
{ path: 'security', name: 'Security', component: SecurityView },
|
||||||
|
{ path: 'projects/:id', name: 'ProjectDetail', component: ProjectDetailView },
|
||||||
|
{ path: 'settings', name: 'Settings', component: SettingsView },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
{ path: '/memory', name: 'Memory', component: MemoryView, meta: { standalone: true } },
|
|
||||||
{ path: '/docs', name: 'Docs', component: DocsView, meta: { standalone: true } },
|
|
||||||
{ path: '/agents/:id', name: 'AgentDetail', component: AgentDetailView, meta: { standalone: true } },
|
|
||||||
{ path: '/security', name: 'Security', component: SecurityView, meta: { standalone: true } },
|
|
||||||
{ path: '/incidents', name: 'Incidents', component: IncidentsView, meta: { standalone: true } },
|
|
||||||
{ path: '/calendar', name: 'Calendar', component: CalendarView, meta: { standalone: true } },
|
|
||||||
{ path: '/projects', name: 'Projects', component: { template: '' } },
|
|
||||||
{ path: '/projects/:id', name: 'ProjectDetail', component: ProjectDetailView, meta: { standalone: true } },
|
|
||||||
{ path: '/tasks', name: 'Task Board', component: TaskBoardView, meta: { standalone: true } },
|
|
||||||
{ path: '/tasks/:id', name: 'TaskDetail', component: TaskDetailView, meta: { standalone: true } },
|
|
||||||
{ path: '/agents', name: 'Agents', component: AgentsIndexView, meta: { standalone: true } },
|
|
||||||
{ path: '/models', name: 'Models', component: { template: '' } },
|
|
||||||
{ path: '/activity', name: 'Activity', component: { template: '' } },
|
|
||||||
{ path: '/chat', name: 'Mobile Chat', component: { template: '' } },
|
|
||||||
{ path: '/notifications', name: 'Notifications', component: NotificationsView, meta: { standalone: true } },
|
|
||||||
{ path: '/settings', name: 'Settings', component: SettingsView, meta: { standalone: true } },
|
|
||||||
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' },
|
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
import { defineStore } from 'pinia'
|
|
||||||
import { openDashboardLiveStream } from '../services/live'
|
|
||||||
import type { BoardGroup, DashboardTaskDto } from './tasks'
|
|
||||||
import type { NotificationItem } from './notifications'
|
|
||||||
import type { TaskItem } from '../components/dashboard/v2/types'
|
|
||||||
import { useTaskStore } from './tasks'
|
|
||||||
import { useNotificationStore } from './notifications'
|
|
||||||
import type { DashboardLiveEventDto, LiveCursorDto, LiveUpdateEnvelope } from '../services/live'
|
|
||||||
|
|
||||||
interface NotificationSnapshotDto {
|
|
||||||
notifications: NotificationItem[]
|
|
||||||
unreadCount: number
|
|
||||||
forUser: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DashboardLiveSnapshotDto {
|
|
||||||
board: BoardGroup
|
|
||||||
notifications: NotificationSnapshotDto
|
|
||||||
cursor: LiveCursorDto
|
|
||||||
}
|
|
||||||
|
|
||||||
function isBoardGroup(value: unknown): value is BoardGroup {
|
|
||||||
const v = value as BoardGroup
|
|
||||||
return !!v && Array.isArray(v.offen) && Array.isArray(v.inProgress) && Array.isArray(v.review) && Array.isArray(v.blocked) && Array.isArray(v.done)
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapTasks(board: BoardGroup): DashboardTaskDto[] {
|
|
||||||
return [...board.offen, ...board.inProgress, ...board.review, ...board.blocked, ...board.done]
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapTaskStripItem(t: DashboardTaskDto): TaskItem {
|
|
||||||
return {
|
|
||||||
id: t.id,
|
|
||||||
title: t.title,
|
|
||||||
agent: t.assignedTo ?? '—',
|
|
||||||
priority: (['high', 'critical', 'urgent'].includes(t.priority.toLowerCase()) ? 'high' : ['low', 'minor'].includes(t.priority.toLowerCase()) ? 'low' : 'medium') as 'high' | 'medium' | 'low',
|
|
||||||
status: (t.state.toLowerCase() === 'blocked' ? 'blocked' : (['in progress', 'active', 'working'].includes(t.state.toLowerCase()) ? 'active' : 'pending')) as 'active' | 'blocked' | 'pending',
|
|
||||||
progress: t.state.toLowerCase() === 'done' ? 100 : t.state.toLowerCase() === 'blocked' ? 30 : (['in progress', 'active', 'working'].includes(t.state.toLowerCase()) ? 50 : 0),
|
|
||||||
detail: t.detail,
|
|
||||||
source: t.source,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useLiveSyncStore = defineStore('liveSync', {
|
|
||||||
state: () => ({
|
|
||||||
connected: false,
|
|
||||||
connecting: false,
|
|
||||||
lastEventAt: null as string | null,
|
|
||||||
lastHeartbeatAt: null as string | null,
|
|
||||||
error: null as string | null,
|
|
||||||
controller: null as AbortController | null,
|
|
||||||
reconnectTimer: null as ReturnType<typeof setTimeout> | null,
|
|
||||||
mode: 'polling' as 'polling' | 'live',
|
|
||||||
lastSequence: 0,
|
|
||||||
reconnectAttempts: 0,
|
|
||||||
}),
|
|
||||||
|
|
||||||
getters: {
|
|
||||||
liveIndicatorLabel: (state) => {
|
|
||||||
if (state.connecting) return 'Verbinde…'
|
|
||||||
if (state.connected) return `Live · #${state.lastSequence}`
|
|
||||||
return state.mode === 'polling' ? 'Polling' : 'Offline'
|
|
||||||
},
|
|
||||||
connectionHealth: (state) => {
|
|
||||||
if (state.connected) return 'healthy'
|
|
||||||
if (state.connecting) return 'connecting'
|
|
||||||
return 'degraded'
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
actions: {
|
|
||||||
async connect(forUser = 'bao') {
|
|
||||||
if (this.connecting || this.connected) return
|
|
||||||
this.connecting = true
|
|
||||||
this.error = null
|
|
||||||
this.controller = new AbortController()
|
|
||||||
|
|
||||||
const taskStore = useTaskStore()
|
|
||||||
const notificationStore = useNotificationStore()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stream = await openDashboardLiveStream((event, data) => {
|
|
||||||
this.lastEventAt = new Date().toISOString()
|
|
||||||
|
|
||||||
if (event === 'heartbeat') {
|
|
||||||
const cursor = data as LiveCursorDto
|
|
||||||
this.lastHeartbeatAt = cursor.timestamp
|
|
||||||
this.lastSequence = Math.max(this.lastSequence, cursor.sequence)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event === 'snapshot') {
|
|
||||||
const snapshot = data as DashboardLiveSnapshotDto
|
|
||||||
taskStore.board = snapshot.board
|
|
||||||
taskStore.tasks = mapTasks(snapshot.board).map(mapTaskStripItem)
|
|
||||||
notificationStore.notifications = snapshot.notifications.notifications
|
|
||||||
notificationStore.unreadCount = snapshot.notifications.unreadCount
|
|
||||||
this.lastSequence = snapshot.cursor.sequence
|
|
||||||
this.connected = true
|
|
||||||
this.mode = 'live'
|
|
||||||
this.reconnectAttempts = 0
|
|
||||||
taskStore.stopBoardPolling()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventDto = data as DashboardLiveEventDto
|
|
||||||
this.applyEnvelope(eventDto.envelope, forUser)
|
|
||||||
this.lastSequence = eventDto.cursor.sequence
|
|
||||||
this.connected = true
|
|
||||||
this.mode = 'live'
|
|
||||||
this.reconnectAttempts = 0
|
|
||||||
taskStore.stopBoardPolling()
|
|
||||||
}, { forUser, signal: this.controller.signal, afterSequence: this.lastSequence || null })
|
|
||||||
|
|
||||||
await stream.closed
|
|
||||||
} catch (error) {
|
|
||||||
if (this.controller?.signal.aborted) return
|
|
||||||
console.warn('[liveSync] stream failed, falling back to polling', error)
|
|
||||||
this.error = 'Live updates unavailable'
|
|
||||||
this.connected = false
|
|
||||||
this.mode = 'polling'
|
|
||||||
taskStore.startBoardPolling()
|
|
||||||
this.scheduleReconnect(forUser)
|
|
||||||
} finally {
|
|
||||||
this.connecting = false
|
|
||||||
if (!this.controller?.signal.aborted && !this.connected) {
|
|
||||||
this.mode = 'polling'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
applyEnvelope(envelope: LiveUpdateEnvelope, forUser: string) {
|
|
||||||
const taskStore = useTaskStore()
|
|
||||||
const notificationStore = useNotificationStore()
|
|
||||||
|
|
||||||
if (envelope.type === 'tasks.board.snapshot' && isBoardGroup(envelope.payload)) {
|
|
||||||
taskStore.board = envelope.payload
|
|
||||||
taskStore.tasks = mapTasks(envelope.payload).map(mapTaskStripItem)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (envelope.type === 'notifications.snapshot') {
|
|
||||||
const snapshot = envelope.payload as NotificationSnapshotDto
|
|
||||||
if (snapshot.forUser !== forUser) return
|
|
||||||
notificationStore.notifications = snapshot.notifications
|
|
||||||
notificationStore.unreadCount = snapshot.unreadCount
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
disconnect() {
|
|
||||||
this.controller?.abort()
|
|
||||||
this.controller = null
|
|
||||||
this.connected = false
|
|
||||||
this.connecting = false
|
|
||||||
this.mode = 'polling'
|
|
||||||
if (this.reconnectTimer) {
|
|
||||||
clearTimeout(this.reconnectTimer)
|
|
||||||
this.reconnectTimer = null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
scheduleReconnect(forUser = 'bao') {
|
|
||||||
if (this.reconnectTimer) return
|
|
||||||
const delay = Math.min(30000, 5000 * Math.max(1, this.reconnectAttempts + 1))
|
|
||||||
this.reconnectAttempts += 1
|
|
||||||
this.reconnectTimer = setTimeout(() => {
|
|
||||||
this.reconnectTimer = null
|
|
||||||
this.connect(forUser)
|
|
||||||
}, delay)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -50,9 +50,11 @@ export const useLiveSyncStore = defineStore('liveSync', {
|
|||||||
error: null as string | null,
|
error: null as string | null,
|
||||||
controller: null as AbortController | null,
|
controller: null as AbortController | null,
|
||||||
reconnectTimer: null as ReturnType<typeof setTimeout> | null,
|
reconnectTimer: null as ReturnType<typeof setTimeout> | null,
|
||||||
|
watchdogTimer: null as ReturnType<typeof setInterval> | null,
|
||||||
mode: 'polling' as 'polling' | 'live',
|
mode: 'polling' as 'polling' | 'live',
|
||||||
lastSequence: 0,
|
lastSequence: 0,
|
||||||
reconnectAttempts: 0,
|
reconnectAttempts: 0,
|
||||||
|
forUser: 'bao',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
@@ -73,7 +75,9 @@ export const useLiveSyncStore = defineStore('liveSync', {
|
|||||||
if (this.connecting || this.connected) return
|
if (this.connecting || this.connected) return
|
||||||
this.connecting = true
|
this.connecting = true
|
||||||
this.error = null
|
this.error = null
|
||||||
|
this.forUser = forUser
|
||||||
this.controller = new AbortController()
|
this.controller = new AbortController()
|
||||||
|
this.startWatchdog()
|
||||||
|
|
||||||
const taskStore = useTaskStore()
|
const taskStore = useTaskStore()
|
||||||
const notificationStore = useNotificationStore()
|
const notificationStore = useNotificationStore()
|
||||||
@@ -113,19 +117,51 @@ export const useLiveSyncStore = defineStore('liveSync', {
|
|||||||
}, { forUser, signal: this.controller.signal, afterSequence: this.lastSequence || null })
|
}, { forUser, signal: this.controller.signal, afterSequence: this.lastSequence || null })
|
||||||
|
|
||||||
await stream.closed
|
await stream.closed
|
||||||
|
// Stream „sauber" beendet (Proxy-Timeout, Server-Neustart, Netzwechsel):
|
||||||
|
// muss genauso wie ein Fehler behandelt werden — sonst bleibt connected=true
|
||||||
|
// hängen, Polling ist gestoppt und die Seite erhält nie wieder Updates.
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.controller?.signal.aborted) return
|
if (!this.controller?.signal.aborted) {
|
||||||
console.warn('[liveSync] stream failed, falling back to polling', error)
|
console.warn('[liveSync] stream failed', error)
|
||||||
this.error = 'Live updates unavailable'
|
this.error = 'Live updates unavailable'
|
||||||
this.connected = false
|
}
|
||||||
this.mode = 'polling'
|
|
||||||
taskStore.startBoardPolling()
|
|
||||||
this.scheduleReconnect(forUser)
|
|
||||||
} finally {
|
} finally {
|
||||||
this.connecting = false
|
this.connecting = false
|
||||||
if (!this.controller?.signal.aborted && !this.connected) {
|
}
|
||||||
this.mode = 'polling'
|
|
||||||
|
if (this.controller?.signal.aborted) return
|
||||||
|
|
||||||
|
this.connected = false
|
||||||
|
this.mode = 'polling'
|
||||||
|
taskStore.startBoardPolling()
|
||||||
|
this.scheduleReconnect(forUser)
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Erzwingt einen frischen Stream (Watchdog / visibilitychange / online). */
|
||||||
|
reconnectNow() {
|
||||||
|
const forUser = this.forUser
|
||||||
|
this.disconnect()
|
||||||
|
this.connect(forUser)
|
||||||
|
},
|
||||||
|
|
||||||
|
startWatchdog() {
|
||||||
|
if (this.watchdogTimer) return
|
||||||
|
this.watchdogTimer = setInterval(() => {
|
||||||
|
if (!this.connected || !this.lastEventAt) return
|
||||||
|
// Heartbeat kommt alle 20s — >65s Stille heißt: Verbindung ist tot,
|
||||||
|
// auch wenn der Browser den fetch-Stream noch für offen hält.
|
||||||
|
const silentMs = Date.now() - new Date(this.lastEventAt).getTime()
|
||||||
|
if (silentMs > 65000) {
|
||||||
|
console.warn('[liveSync] heartbeat timeout, reconnecting')
|
||||||
|
this.reconnectNow()
|
||||||
}
|
}
|
||||||
|
}, 15000)
|
||||||
|
},
|
||||||
|
|
||||||
|
stopWatchdog() {
|
||||||
|
if (this.watchdogTimer) {
|
||||||
|
clearInterval(this.watchdogTimer)
|
||||||
|
this.watchdogTimer = null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -148,6 +184,7 @@ export const useLiveSyncStore = defineStore('liveSync', {
|
|||||||
},
|
},
|
||||||
|
|
||||||
disconnect() {
|
disconnect() {
|
||||||
|
this.stopWatchdog()
|
||||||
this.controller?.abort()
|
this.controller?.abort()
|
||||||
this.controller = null
|
this.controller = null
|
||||||
this.connected = false
|
this.connected = false
|
||||||
@@ -161,7 +198,9 @@ export const useLiveSyncStore = defineStore('liveSync', {
|
|||||||
|
|
||||||
scheduleReconnect(forUser = 'bao') {
|
scheduleReconnect(forUser = 'bao') {
|
||||||
if (this.reconnectTimer) return
|
if (this.reconnectTimer) return
|
||||||
const delay = Math.min(30000, 5000 * Math.max(1, this.reconnectAttempts + 1))
|
// Erster Retry schnell (1s) — der häufigste Fall ist ein Proxy-/Deploy-Cut,
|
||||||
|
// danach sanft hochstaffeln bis 30s.
|
||||||
|
const delay = this.reconnectAttempts === 0 ? 1000 : Math.min(30000, 5000 * this.reconnectAttempts)
|
||||||
this.reconnectAttempts += 1
|
this.reconnectAttempts += 1
|
||||||
this.reconnectTimer = setTimeout(() => {
|
this.reconnectTimer = setTimeout(() => {
|
||||||
this.reconnectTimer = null
|
this.reconnectTimer = null
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { useAgentStore } from '../../stores/agents'
|
|||||||
import { useChatStore } from '../../stores/chat'
|
import { useChatStore } from '../../stores/chat'
|
||||||
import { useDashboardStore } from '../../stores/dashboard'
|
import { useDashboardStore } from '../../stores/dashboard'
|
||||||
import { useTaskStore } from '../../stores/tasks'
|
import { useTaskStore } from '../../stores/tasks'
|
||||||
import { useLiveSyncStore } from '../../stores/liveSync'
|
|
||||||
import AlertBar from '../../components/dashboard/v2/AlertBar.vue'
|
import AlertBar from '../../components/dashboard/v2/AlertBar.vue'
|
||||||
import FlowCanvas from '../../components/dashboard/v2/FlowCanvas.vue'
|
import FlowCanvas from '../../components/dashboard/v2/FlowCanvas.vue'
|
||||||
import IrisChat from '../../components/dashboard/v2/IrisChat.vue'
|
import IrisChat from '../../components/dashboard/v2/IrisChat.vue'
|
||||||
@@ -31,7 +30,6 @@ const agentStore = useAgentStore()
|
|||||||
const chatStore = useChatStore()
|
const chatStore = useChatStore()
|
||||||
const dashboardStore = useDashboardStore()
|
const dashboardStore = useDashboardStore()
|
||||||
const taskStore = useTaskStore()
|
const taskStore = useTaskStore()
|
||||||
const liveSyncStore = useLiveSyncStore()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -70,7 +68,6 @@ onMounted(() => {
|
|||||||
dashboardStore.startPolling()
|
dashboardStore.startPolling()
|
||||||
taskStore.startPolling()
|
taskStore.startPolling()
|
||||||
taskStore.startBoardPolling()
|
taskStore.startBoardPolling()
|
||||||
liveSyncStore.connect()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -79,7 +76,6 @@ onUnmounted(() => {
|
|||||||
dashboardStore.stopPolling()
|
dashboardStore.stopPolling()
|
||||||
taskStore.stopPolling()
|
taskStore.stopPolling()
|
||||||
taskStore.stopBoardPolling()
|
taskStore.stopBoardPolling()
|
||||||
liveSyncStore.disconnect()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,10 @@
|
|||||||
import { onMounted, onUnmounted, computed } from 'vue'
|
import { onMounted, onUnmounted, computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useNotificationStore } from '../stores/notifications'
|
import { useNotificationStore } from '../stores/notifications'
|
||||||
import { useLiveSyncStore } from '../stores/liveSync'
|
|
||||||
import { Bell, BellOff, CheckCheck, ChevronRight } from '@lucide/vue'
|
import { Bell, BellOff, CheckCheck, ChevronRight } from '@lucide/vue'
|
||||||
|
|
||||||
const store = useNotificationStore()
|
const store = useNotificationStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const liveSyncStore = useLiveSyncStore()
|
|
||||||
|
|
||||||
const sortedNotifications = computed(() => {
|
const sortedNotifications = computed(() => {
|
||||||
return [...store.notifications].sort(
|
return [...store.notifications].sort(
|
||||||
@@ -55,12 +53,10 @@ function onNotificationClick(n: { id: string, taskId: string | null }) {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
store.startListPolling()
|
store.startListPolling()
|
||||||
liveSyncStore.connect()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
store.stopListPolling()
|
store.stopListPolling()
|
||||||
liveSyncStore.disconnect()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { Plus, X, CalendarDays, Clock3, ExternalLink, Link2, ListChecks, Save, A
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useTaskStore } from '../stores/tasks'
|
import { useTaskStore } from '../stores/tasks'
|
||||||
import { useLiveSyncStore } from '../stores/live-sync'
|
import { useLiveSyncStore } from '../stores/liveSync'
|
||||||
import { TASK_AGENT_LABELS, TASK_AGENT_OPTIONS } from '../constants/agentPool'
|
import { TASK_AGENT_LABELS, TASK_AGENT_OPTIONS } from '../constants/agentPool'
|
||||||
|
|
||||||
type BoardTask = ReturnType<typeof flattenBoard>[number]
|
type BoardTask = ReturnType<typeof flattenBoard>[number]
|
||||||
@@ -393,7 +393,6 @@ let agentOverviewInterval: ReturnType<typeof setInterval> | null = null
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
taskStore.startBoardPolling()
|
taskStore.startBoardPolling()
|
||||||
taskStore.fetchAgentOverview()
|
taskStore.fetchAgentOverview()
|
||||||
liveSyncStore.connect()
|
|
||||||
window.addEventListener('keydown', onGlobalKeydown)
|
window.addEventListener('keydown', onGlobalKeydown)
|
||||||
agentOverviewInterval = setInterval(() => taskStore.fetchAgentOverview(), 30000)
|
agentOverviewInterval = setInterval(() => taskStore.fetchAgentOverview(), 30000)
|
||||||
})
|
})
|
||||||
@@ -404,7 +403,6 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
taskStore.stopBoardPolling()
|
taskStore.stopBoardPolling()
|
||||||
liveSyncStore.disconnect()
|
|
||||||
if (agentOverviewInterval) clearInterval(agentOverviewInterval)
|
if (agentOverviewInterval) clearInterval(agentOverviewInterval)
|
||||||
window.removeEventListener('keydown', onGlobalKeydown)
|
window.removeEventListener('keydown', onGlobalKeydown)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user