From 85813a9cc595c27f74d128d8d41b144e9e53a508 Mon Sep 17 00:00:00 2001 From: Ali Ramlaoui Date: Mon, 4 May 2026 19:39:21 +0200 Subject: [PATCH] feat: add mobile app and job UI fixes --- config.example.yaml | 2 + mobile-app/.gitignore | 41 + mobile-app/.vscode/extensions.json | 1 + mobile-app/.vscode/settings.json | 7 + mobile-app/app.json | 42 + mobile-app/app/(tabs)/_layout.tsx | 56 + mobile-app/app/(tabs)/jobs.tsx | 123 + mobile-app/app/(tabs)/launch.tsx | 202 + mobile-app/app/(tabs)/settings.tsx | 131 + mobile-app/app/(tabs)/watchers.tsx | 137 + mobile-app/app/+html.tsx | 38 + mobile-app/app/+not-found.tsx | 41 + mobile-app/app/_layout.tsx | 81 + mobile-app/app/index.tsx | 5 + mobile-app/app/job/[jobId].tsx | 237 + mobile-app/assets/fonts/SpaceMono-Regular.ttf | Bin 0 -> 93252 bytes mobile-app/assets/images/adaptive-icon.png | Bin 0 -> 17547 bytes mobile-app/assets/images/favicon.png | Bin 0 -> 1466 bytes mobile-app/assets/images/icon.png | Bin 0 -> 22380 bytes mobile-app/assets/images/splash-icon.png | Bin 0 -> 17547 bytes mobile-app/jest.config.js | 5 + mobile-app/jest.setup.ts | 9 + mobile-app/package-lock.json | 13000 ++++++++++++++++ mobile-app/package.json | 59 + mobile-app/src/api/client.ts | 322 + mobile-app/src/api/schemas.test.ts | 64 + mobile-app/src/api/schemas.ts | 291 + .../src/components/ConnectionBanner.tsx | 47 + mobile-app/src/components/EmptyState.tsx | 35 + mobile-app/src/components/JobCard.tsx | 71 + mobile-app/src/components/MetricPill.tsx | 35 + mobile-app/src/components/PrimaryButton.tsx | 62 + mobile-app/src/components/Screen.tsx | 86 + mobile-app/src/components/SectionCard.tsx | 59 + .../src/components/SegmentedControl.tsx | 63 + mobile-app/src/components/StatusBadge.tsx | 32 + mobile-app/src/components/TextField.tsx | 58 + mobile-app/src/components/WatcherCard.tsx | 102 + mobile-app/src/features/jobs/hooks.ts | 112 + .../features/jobs/job-preferences-store.ts | 33 + mobile-app/src/features/launch/hooks.ts | 25 + .../src/features/launch/launch-draft-store.ts | 43 + .../src/features/live/useJobsRealtime.ts | 145 + .../src/features/live/useLaunchEventStream.ts | 49 + .../src/features/live/useOutputStream.ts | 109 + .../src/features/live/useWatchersRealtime.ts | 68 + .../src/features/notifications/hooks.ts | 73 + .../features/session/session-store.test.ts | 51 + .../src/features/session/session-store.ts | 115 + mobile-app/src/features/watchers/hooks.ts | 113 + mobile-app/src/lib/errors.ts | 19 + mobile-app/src/lib/format.ts | 95 + mobile-app/src/lib/network.ts | 13 + mobile-app/src/providers/AppProviders.tsx | 22 + mobile-app/src/theme/colors.ts | 23 + mobile-app/src/theme/spacing.ts | 15 + mobile-app/tsconfig.json | 17 + src/ssync/job_data_manager.py | 9 +- src/ssync/models/cluster.py | 8 + src/ssync/ssh/connection.py | 18 +- src/ssync/ssh/native.py | 123 +- src/ssync/utils/config.py | 24 + src/ssync/web/services/jobs.py | 120 +- tests/unit/test_job_data_manager.py | 53 + tests/unit/test_ssh_backend.py | 24 + tests/unit/test_watcher_actions.py | 114 + tests/unit/test_web_job_output.py | 73 + .../src/components/ArrayJobCard.svelte | 4 +- .../src/components/CodeMirrorEditor.svelte | 50 +- .../src/components/ErrorBoundary.svelte | 12 + web-frontend/src/components/JobHeader.svelte | 6 +- .../src/components/JobLauncher.svelte | 42 +- web-frontend/src/components/JobSidebar.svelte | 352 +- web-frontend/src/components/JobTable.svelte | 4 +- .../src/components/OutputViewer.svelte | 6 +- .../src/components/SaveTemplateDialog.svelte | 7 + .../src/components/ScriptViewer.svelte | 6 +- .../src/components/WatchersTab.svelte | 482 +- web-frontend/src/global.css | 6 +- web-frontend/src/lib/actions/clickOutside.ts | 6 +- .../src/lib/components/ui/Dialog.svelte | 27 +- web-frontend/src/pages/JobPage.svelte | 29 +- web-frontend/src/pages/JobsPage.svelte | 333 +- 83 files changed, 18466 insertions(+), 156 deletions(-) create mode 100644 mobile-app/.gitignore create mode 100644 mobile-app/.vscode/extensions.json create mode 100644 mobile-app/.vscode/settings.json create mode 100644 mobile-app/app.json create mode 100644 mobile-app/app/(tabs)/_layout.tsx create mode 100644 mobile-app/app/(tabs)/jobs.tsx create mode 100644 mobile-app/app/(tabs)/launch.tsx create mode 100644 mobile-app/app/(tabs)/settings.tsx create mode 100644 mobile-app/app/(tabs)/watchers.tsx create mode 100644 mobile-app/app/+html.tsx create mode 100644 mobile-app/app/+not-found.tsx create mode 100644 mobile-app/app/_layout.tsx create mode 100644 mobile-app/app/index.tsx create mode 100644 mobile-app/app/job/[jobId].tsx create mode 100755 mobile-app/assets/fonts/SpaceMono-Regular.ttf create mode 100644 mobile-app/assets/images/adaptive-icon.png create mode 100644 mobile-app/assets/images/favicon.png create mode 100644 mobile-app/assets/images/icon.png create mode 100644 mobile-app/assets/images/splash-icon.png create mode 100644 mobile-app/jest.config.js create mode 100644 mobile-app/jest.setup.ts create mode 100644 mobile-app/package-lock.json create mode 100644 mobile-app/package.json create mode 100644 mobile-app/src/api/client.ts create mode 100644 mobile-app/src/api/schemas.test.ts create mode 100644 mobile-app/src/api/schemas.ts create mode 100644 mobile-app/src/components/ConnectionBanner.tsx create mode 100644 mobile-app/src/components/EmptyState.tsx create mode 100644 mobile-app/src/components/JobCard.tsx create mode 100644 mobile-app/src/components/MetricPill.tsx create mode 100644 mobile-app/src/components/PrimaryButton.tsx create mode 100644 mobile-app/src/components/Screen.tsx create mode 100644 mobile-app/src/components/SectionCard.tsx create mode 100644 mobile-app/src/components/SegmentedControl.tsx create mode 100644 mobile-app/src/components/StatusBadge.tsx create mode 100644 mobile-app/src/components/TextField.tsx create mode 100644 mobile-app/src/components/WatcherCard.tsx create mode 100644 mobile-app/src/features/jobs/hooks.ts create mode 100644 mobile-app/src/features/jobs/job-preferences-store.ts create mode 100644 mobile-app/src/features/launch/hooks.ts create mode 100644 mobile-app/src/features/launch/launch-draft-store.ts create mode 100644 mobile-app/src/features/live/useJobsRealtime.ts create mode 100644 mobile-app/src/features/live/useLaunchEventStream.ts create mode 100644 mobile-app/src/features/live/useOutputStream.ts create mode 100644 mobile-app/src/features/live/useWatchersRealtime.ts create mode 100644 mobile-app/src/features/notifications/hooks.ts create mode 100644 mobile-app/src/features/session/session-store.test.ts create mode 100644 mobile-app/src/features/session/session-store.ts create mode 100644 mobile-app/src/features/watchers/hooks.ts create mode 100644 mobile-app/src/lib/errors.ts create mode 100644 mobile-app/src/lib/format.ts create mode 100644 mobile-app/src/lib/network.ts create mode 100644 mobile-app/src/providers/AppProviders.tsx create mode 100644 mobile-app/src/theme/colors.ts create mode 100644 mobile-app/src/theme/spacing.ts create mode 100644 mobile-app/tsconfig.json create mode 100644 tests/unit/test_watcher_actions.py diff --git a/config.example.yaml b/config.example.yaml index 64071b9..ed2ada4 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -70,6 +70,8 @@ cache: cleanup_interval_hours: 168 # Run cleanup weekly max_size_mb: 1024 # Maximum cache size in MB auto_cleanup: false # Whether to automatically cleanup old entries + refresh_running_outputs: true # Refresh cached .out/.err files for running jobs when output is requested + running_output_refresh_interval_seconds: 10 # Per-job throttle for running-output cache refreshes # Connection settings connections: diff --git a/mobile-app/.gitignore b/mobile-app/.gitignore new file mode 100644 index 0000000..d914c32 --- /dev/null +++ b/mobile-app/.gitignore @@ -0,0 +1,41 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +.kotlin/ +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo + +# generated native folders +/ios +/android diff --git a/mobile-app/.vscode/extensions.json b/mobile-app/.vscode/extensions.json new file mode 100644 index 0000000..b7ed837 --- /dev/null +++ b/mobile-app/.vscode/extensions.json @@ -0,0 +1 @@ +{ "recommendations": ["expo.vscode-expo-tools"] } diff --git a/mobile-app/.vscode/settings.json b/mobile-app/.vscode/settings.json new file mode 100644 index 0000000..e2798e4 --- /dev/null +++ b/mobile-app/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "editor.codeActionsOnSave": { + "source.fixAll": "explicit", + "source.organizeImports": "explicit", + "source.sortMembers": "explicit" + } +} diff --git a/mobile-app/app.json b/mobile-app/app.json new file mode 100644 index 0000000..869387d --- /dev/null +++ b/mobile-app/app.json @@ -0,0 +1,42 @@ +{ + "expo": { + "name": "ssync Mobile", + "slug": "ssync-mobile", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/images/icon.png", + "scheme": "ssyncmobile", + "userInterfaceStyle": "dark", + "newArchEnabled": true, + "splash": { + "image": "./assets/images/splash-icon.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.ssync.mobile" + }, + "android": { + "package": "com.ssync.mobile", + "adaptiveIcon": { + "foregroundImage": "./assets/images/adaptive-icon.png", + "backgroundColor": "#ffffff" + }, + "edgeToEdgeEnabled": true, + "predictiveBackGestureEnabled": false + }, + "web": { + "bundler": "metro", + "output": "static", + "favicon": "./assets/images/favicon.png" + }, + "plugins": [ + "expo-router", + "expo-secure-store" + ], + "experiments": { + "typedRoutes": true + } + } +} diff --git a/mobile-app/app/(tabs)/_layout.tsx b/mobile-app/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..f5009ea --- /dev/null +++ b/mobile-app/app/(tabs)/_layout.tsx @@ -0,0 +1,56 @@ +import FontAwesome from '@expo/vector-icons/FontAwesome'; +import { Tabs } from 'expo-router'; + +import { colors } from '@/src/theme/colors'; + +// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/ +function TabBarIcon(props: { + name: React.ComponentProps['name']; + color: string; +}) { + return ; +} + +export default function TabLayout() { + return ( + + , + }} + /> + , + }} + /> + , + }} + /> + , + }} + /> + + ); +} diff --git a/mobile-app/app/(tabs)/jobs.tsx b/mobile-app/app/(tabs)/jobs.tsx new file mode 100644 index 0000000..ba85ba5 --- /dev/null +++ b/mobile-app/app/(tabs)/jobs.tsx @@ -0,0 +1,123 @@ +import { useDeferredValue } from 'react'; +import { ActivityIndicator, FlatList, StyleSheet, Text, View } from 'react-native'; +import { router } from 'expo-router'; + +import { Screen } from '@/src/components/Screen'; +import { ConnectionBanner } from '@/src/components/ConnectionBanner'; +import { EmptyState } from '@/src/components/EmptyState'; +import { JobCard } from '@/src/components/JobCard'; +import { PrimaryButton } from '@/src/components/PrimaryButton'; +import { SectionCard } from '@/src/components/SectionCard'; +import { TextField } from '@/src/components/TextField'; +import { useFlattenedJobs } from '@/src/features/jobs/hooks'; +import { useJobFiltersStore } from '@/src/features/jobs/job-preferences-store'; +import { useJobsRealtime } from '@/src/features/live/useJobsRealtime'; +import { useSessionStore } from '@/src/features/session/session-store'; +import { colors } from '@/src/theme/colors'; +import { spacing } from '@/src/theme/spacing'; + +export default function JobsScreen() { + const search = useJobFiltersStore((state) => state.search); + const host = useJobFiltersStore((state) => state.host); + const state = useJobFiltersStore((value) => value.state); + const user = useJobFiltersStore((value) => value.user); + const setSearch = useJobFiltersStore((value) => value.setSearch); + const baseUrl = useSessionStore((value) => value.baseUrl); + const connection = useSessionStore((value) => ({ + source: value.connectionSource, + websocketHealthy: value.websocketHealthy, + lastSyncAt: value.lastSyncAt, + lastError: value.lastError, + })); + + const deferredSearch = useDeferredValue(search); + const query = useFlattenedJobs({ + host, + user, + state, + limit: 200, + }); + + useJobsRealtime(Boolean(baseUrl)); + + const items = query.items.filter((job) => { + const haystack = `${job.name} ${job.job_id} ${job.hostname} ${job.user ?? ''}`.toLowerCase(); + return haystack.includes(deferredSearch.trim().toLowerCase()); + }); + + return ( + { + query.refetch(); + }} + /> + } + > + + + {!baseUrl ? ( + + ) : null} + + + + + + : null}> + {query.isLoading ? ( + + ) : items.length === 0 ? ( + + ) : ( + + `${item.hostname}:${item.job_id}`} + renderItem={({ item }) => ( + + router.push({ + pathname: '/job/[jobId]', + params: { jobId: item.job_id, hostname: item.hostname }, + }) + } + /> + )} + ItemSeparatorComponent={() => } + /> + + )} + + + {query.error ? {`${query.error}`} : null} + + ); +} + +const styles = StyleSheet.create({ + listContainer: { + minHeight: 440, + }, + error: { + color: colors.danger, + }, +}); diff --git a/mobile-app/app/(tabs)/launch.tsx b/mobile-app/app/(tabs)/launch.tsx new file mode 100644 index 0000000..9f0d0c8 --- /dev/null +++ b/mobile-app/app/(tabs)/launch.tsx @@ -0,0 +1,202 @@ +import { useMemo, useState } from 'react'; +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; + +import { Screen } from '@/src/components/Screen'; +import { EmptyState } from '@/src/components/EmptyState'; +import { PrimaryButton } from '@/src/components/PrimaryButton'; +import { SectionCard } from '@/src/components/SectionCard'; +import { TextField } from '@/src/components/TextField'; +import { useLaunchJob, useLaunchStatus } from '@/src/features/launch/hooks'; +import { useLaunchEventStream } from '@/src/features/live/useLaunchEventStream'; +import { useLaunchDraftStore } from '@/src/features/launch/launch-draft-store'; +import { useSessionStore } from '@/src/features/session/session-store'; +import { colors } from '@/src/theme/colors'; +import { radius, spacing } from '@/src/theme/spacing'; + +function parseOptionalInt(value: string) { + const trimmed = value.trim(); + return trimmed ? Number.parseInt(trimmed, 10) : undefined; +} + +export default function LaunchScreen() { + const baseUrl = useSessionStore((state) => state.baseUrl); + const host = useLaunchDraftStore((state) => state.host); + const sourceDir = useLaunchDraftStore((state) => state.sourceDir); + const scriptContent = useLaunchDraftStore((state) => state.scriptContent); + const jobName = useLaunchDraftStore((state) => state.jobName); + const partition = useLaunchDraftStore((state) => state.partition); + const cpus = useLaunchDraftStore((state) => state.cpus); + const mem = useLaunchDraftStore((state) => state.mem); + const time = useLaunchDraftStore((state) => state.time); + const setField = useLaunchDraftStore((state) => state.setField); + const resetDraft = useLaunchDraftStore((state) => state.reset); + const [lastLaunchId, setLastLaunchId] = useState(null); + + const launchJob = useLaunchJob(); + const launchStatus = useLaunchStatus(lastLaunchId); + + useLaunchEventStream(lastLaunchId); + + const launchDisabled = useMemo( + () => !host.trim() || !scriptContent.trim(), + [host, scriptContent], + ); + + return ( + + {!baseUrl ? ( + + ) : null} + + + setField('host', value)} placeholder="cluster-a" /> + setField('sourceDir', value)} + placeholder="/path/on-the-ssync-host" + hint="Optional. Leave empty to submit script-only jobs without sync." + /> + setField('jobName', value)} placeholder="train-run" /> + + + setField('partition', value)} placeholder="gpu" /> + + + setField('cpus', value)} placeholder="4" keyboardType="numeric" /> + + + + + setField('mem', value)} placeholder="16" keyboardType="numeric" /> + + + setField('time', value)} placeholder="60" keyboardType="numeric" /> + + + setField('scriptContent', value)} + multiline + autoCapitalize="none" + autoCorrect={false} + /> + + + { + const result = await launchJob.mutateAsync({ + host: host.trim(), + source_dir: sourceDir.trim() || undefined, + script_content: scriptContent, + job_name: jobName.trim() || undefined, + partition: partition.trim() || undefined, + cpus: parseOptionalInt(cpus), + mem: parseOptionalInt(mem), + time: parseOptionalInt(time), + exclude: [], + include: [], + no_gitignore: false, + }); + + setLastLaunchId(result.launch_id ?? null); + }} + /> + + + + + + {launchJob.error ? {`${launchJob.error}`} : null} + + + : null} + > + {!lastLaunchId ? ( + + ) : ( + <> + + {launchStatus.data?.stage ?? 'accepted'} + {launchStatus.data?.message ?? 'Waiting for launch events...'} + + + {(launchStatus.data?.events ?? []).slice(-10).reverse().map((event) => ( + + {event.type} + {event.message ?? event.stage ?? 'No message'} + + ))} + + + )} + + + ); +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + gap: spacing.sm, + }, + field: { + flex: 1, + }, + statusHero: { + borderRadius: radius.md, + backgroundColor: colors.surfaceElevated, + padding: spacing.md, + gap: spacing.xs, + }, + statusStage: { + color: colors.primary, + fontSize: 20, + fontWeight: '800', + textTransform: 'uppercase', + }, + statusMessage: { + color: colors.text, + fontSize: 14, + lineHeight: 20, + }, + eventList: { + gap: spacing.sm, + }, + eventRow: { + borderLeftWidth: 2, + borderLeftColor: colors.border, + paddingLeft: spacing.md, + gap: 2, + }, + eventType: { + color: colors.textDim, + fontSize: 12, + textTransform: 'uppercase', + letterSpacing: 0.6, + }, + eventMessage: { + color: colors.text, + lineHeight: 20, + }, + error: { + color: colors.danger, + }, +}); diff --git a/mobile-app/app/(tabs)/settings.tsx b/mobile-app/app/(tabs)/settings.tsx new file mode 100644 index 0000000..e9ba281 --- /dev/null +++ b/mobile-app/app/(tabs)/settings.tsx @@ -0,0 +1,131 @@ +import { Platform, StyleSheet, Switch, Text, View } from 'react-native'; +import { useState } from 'react'; + +import { Screen } from '@/src/components/Screen'; +import { EmptyState } from '@/src/components/EmptyState'; +import { PrimaryButton } from '@/src/components/PrimaryButton'; +import { SectionCard } from '@/src/components/SectionCard'; +import { TextField } from '@/src/components/TextField'; +import { + useNotificationPreferences, + useRegisterDevice, + useUpdateNotificationPreferences, +} from '@/src/features/notifications/hooks'; +import { useSessionStore } from '@/src/features/session/session-store'; +import { api } from '@/src/api/client'; +import { colors } from '@/src/theme/colors'; + +export default function SettingsScreen() { + const baseUrl = useSessionStore((state) => state.baseUrl); + const apiKey = useSessionStore((state) => state.apiKey); + const saveSession = useSessionStore((state) => state.saveSession); + const clearSession = useSessionStore((state) => state.clearSession); + const isSaving = useSessionStore((state) => state.isSaving); + const [draftBaseUrl, setDraftBaseUrl] = useState(baseUrl); + const [draftApiKey, setDraftApiKey] = useState(apiKey); + const [connectionMessage, setConnectionMessage] = useState(null); + + const notificationPreferences = useNotificationPreferences(); + const registerDevice = useRegisterDevice(); + const updateNotificationPreferences = useUpdateNotificationPreferences(); + + return ( + + + + + saveSession({ baseUrl: draftBaseUrl, apiKey: draftApiKey })} + /> + { + try { + await saveSession({ baseUrl: draftBaseUrl, apiKey: draftApiKey }); + const health = await api.healthcheck(); + setConnectionMessage(health.message ?? health.status ?? 'Server responded successfully.'); + } catch (error) { + setConnectionMessage(`${error}`); + } + }} + /> + + {connectionMessage ? {connectionMessage} : null} + + + + {Platform.OS !== 'ios' ? ( + + ) : ( + <> + + + Enable job notifications + Sync alert preferences to the ssync server. + + updateNotificationPreferences.mutate(value)} + /> + + registerDevice.mutate()} + /> + + )} + {registerDevice.error ? {`${registerDevice.error}`} : null} + + + ); +} + +const styles = StyleSheet.create({ + message: { + color: colors.textMuted, + }, + toggleRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, + toggleTitle: { + color: colors.text, + fontWeight: '700', + }, + toggleBody: { + color: colors.textMuted, + fontSize: 13, + lineHeight: 18, + }, + error: { + color: colors.danger, + }, +}); diff --git a/mobile-app/app/(tabs)/watchers.tsx b/mobile-app/app/(tabs)/watchers.tsx new file mode 100644 index 0000000..5f3283c --- /dev/null +++ b/mobile-app/app/(tabs)/watchers.tsx @@ -0,0 +1,137 @@ +import { ActivityIndicator, ScrollView, StyleSheet, Text, View } from 'react-native'; + +import { Screen } from '@/src/components/Screen'; +import { EmptyState } from '@/src/components/EmptyState'; +import { MetricPill } from '@/src/components/MetricPill'; +import { SectionCard } from '@/src/components/SectionCard'; +import { WatcherCard } from '@/src/components/WatcherCard'; +import { + useDeleteWatcher, + usePauseWatcher, + useResumeWatcher, + useTriggerWatcher, + useWatcherEvents, + useWatcherStats, + useWatchersList, +} from '@/src/features/watchers/hooks'; +import { useWatchersRealtime } from '@/src/features/live/useWatchersRealtime'; +import { useSessionStore } from '@/src/features/session/session-store'; +import { colors } from '@/src/theme/colors'; + +export default function WatchersScreen() { + const baseUrl = useSessionStore((state) => state.baseUrl); + const watchers = useWatchersList(); + const watcherEvents = useWatcherEvents(); + const watcherStats = useWatcherStats(); + const pauseWatcher = usePauseWatcher(); + const resumeWatcher = useResumeWatcher(); + const triggerWatcher = useTriggerWatcher(); + const deleteWatcher = useDeleteWatcher(); + + useWatchersRealtime(Boolean(baseUrl)); + + return ( + + : null}> + {watcherStats.data ? ( + + + + + + ) : ( + + )} + + + : null}> + {!watchers.data?.length ? ( + + ) : ( + + {watchers.data.map((watcher) => ( + + watcher.state === 'active' + ? pauseWatcher.mutate(watcher.id) + : resumeWatcher.mutate(watcher.id) + } + secondaryLabel="Trigger" + onSecondaryAction={() => triggerWatcher.mutate(watcher.id)} + /> + ))} + + )} + + + + {!watcherEvents.data?.length ? ( + + ) : ( + + {watcherEvents.data.slice(0, 12).map((event) => ( + + + {event.watcher_name} + + {event.hostname} • {event.action_type} • {event.success ? 'success' : 'failed'} + + {event.action_result ? {event.action_result} : null} + + deleteWatcher.mutate(event.watcher_id)}> + delete + + + ))} + + )} + + + ); +} + +const styles = StyleSheet.create({ + metrics: { + flexDirection: 'row', + gap: 12, + }, + cards: { + gap: 12, + }, + eventScroll: { + maxHeight: 360, + }, + eventRow: { + flexDirection: 'row', + gap: 12, + paddingVertical: 10, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.border, + }, + eventName: { + color: colors.text, + fontWeight: '700', + }, + eventMeta: { + color: colors.textMuted, + fontSize: 12, + lineHeight: 18, + }, + eventResult: { + color: colors.textDim, + fontSize: 12, + lineHeight: 18, + }, + delete: { + color: colors.danger, + fontWeight: '700', + textTransform: 'uppercase', + }, +}); diff --git a/mobile-app/app/+html.tsx b/mobile-app/app/+html.tsx new file mode 100644 index 0000000..6646145 --- /dev/null +++ b/mobile-app/app/+html.tsx @@ -0,0 +1,38 @@ +import { ScrollViewStyleReset } from 'expo-router/html'; + +// This file is web-only and used to configure the root HTML for every +// web page during static rendering. +// The contents of this function only run in Node.js environments and +// do not have access to the DOM or browser APIs. +export default function Root({ children }: { children: React.ReactNode }) { + return ( + + + + + + + {/* + Disable body scrolling on web. This makes ScrollView components work closer to how they do on native. + However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line. + */} + + + {/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */} + \ No newline at end of file + diff --git a/web-frontend/src/components/CodeMirrorEditor.svelte b/web-frontend/src/components/CodeMirrorEditor.svelte index e6d38d1..402dafb 100644 --- a/web-frontend/src/components/CodeMirrorEditor.svelte +++ b/web-frontend/src/components/CodeMirrorEditor.svelte @@ -73,8 +73,15 @@ function getThemeExtension() { const isDark = theme === 'dark' || theme === 'dracula'; // Softer, more consistent colors with the page theme - const backgroundColor = theme === 'dracula' ? '#282a36' : (isDark ? '#1a1f2e' : (isMobile ? '#fafbfc' : '#fafbfc')); - const foregroundColor = theme === 'dracula' ? '#f8f8f2' : (isDark ? '#e2e8f0' : '#374151'); + const backgroundColor = theme === 'dracula' ? '#282a36' : (isDark ? '#171717' : (isMobile ? '#fafbfc' : '#fafbfc')); + const foregroundColor = theme === 'dracula' ? '#f8f8f2' : (isDark ? '#d4d4d4' : '#374151'); + const accentColor = theme === 'dracula' ? '#ff79c6' : (isDark ? '#60a5fa' : (isMobile ? '#6366f1' : '#10b981')); + const selectionColor = theme === 'dracula' + ? 'rgba(255, 121, 198, 0.24)' + : (isDark ? 'rgba(96, 165, 250, 0.22)' : 'rgba(16, 185, 129, 0.18)'); + const focusedSelectionColor = theme === 'dracula' + ? 'rgba(255, 121, 198, 0.3)' + : (isDark ? 'rgba(96, 165, 250, 0.28)' : 'rgba(16, 185, 129, 0.24)'); const mobileFont = '"SF Mono", "Monaco", "Menlo", "Consolas", "Courier New", monospace'; const desktopFont = '"JetBrains Mono", "Monaco", "Menlo", "Ubuntu Mono", monospace'; @@ -101,7 +108,7 @@ color: foregroundColor, fontSize: baseFontSize, fontFamily: fontFamily, - caretColor: theme === 'dracula' ? '#ff79c6' : (isMobile ? '#6366f1' : '#10b981'), + caretColor: accentColor, lineHeight: lineHeight, letterSpacing: isMobile ? '0.01em' : 'normal', }, @@ -118,20 +125,26 @@ lineHeight: lineHeight, }, '.cm-cursor': { - borderColor: theme === 'dracula' ? '#ff79c6' : (isMobile ? '#6366f1' : '#10b981'), + borderColor: accentColor, borderWidth: isMobile ? '2px' : '1px', }, '.cm-selectionBackground': { - backgroundColor: theme === 'dracula' ? 'rgba(255, 121, 198, 0.2)' : 'rgba(16, 185, 129, 0.2)', + backgroundColor: selectionColor, + }, + '.cm-selectionLayer .cm-selectionBackground': { + backgroundColor: selectionColor, + }, + '&.cm-focused .cm-selectionBackground': { + backgroundColor: focusedSelectionColor, }, '.cm-activeLine': { - backgroundColor: isDark ? 'rgba(255, 255, 255, 0.02)' : 'rgba(0, 0, 0, 0.02)', + backgroundColor: isDark ? 'rgba(255, 255, 255, 0.035)' : 'rgba(0, 0, 0, 0.02)', }, '.cm-activeLineGutter': { - backgroundColor: isDark ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.05)', + backgroundColor: isDark ? 'rgba(255, 255, 255, 0.045)' : 'rgba(0, 0, 0, 0.05)', }, '.cm-gutters': { - backgroundColor: isDark ? 'rgba(0, 0, 0, 0.1)' : 'transparent', + backgroundColor: isDark ? '#1f1f1f' : 'transparent', color: isDark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(156, 163, 175, 0.8)', border: 'none', fontSize: baseFontSize, @@ -531,6 +544,27 @@ background: rgba(16, 185, 129, 0.2) !important; } + :global(.dark .cm-selectionBackground), + :global(.dark .cm-selectionLayer .cm-selectionBackground) { + background: rgba(96, 165, 250, 0.22) !important; + } + + :global(.dark .cm-focused .cm-selectionBackground) { + background: rgba(96, 165, 250, 0.28) !important; + } + + :global(.dark .cm-content) { + caret-color: #60a5fa !important; + } + + :global(.dark .cm-focused .cm-cursor) { + border-color: #60a5fa !important; + } + + :global(.dark .cm-editor ::selection) { + background: rgba(96, 165, 250, 0.28) !important; + } + :global(.cm-vim-panel) { background: #1a202c !important; border-top: 1px solid rgba(255, 255, 255, 0.1) !important; diff --git a/web-frontend/src/components/ErrorBoundary.svelte b/web-frontend/src/components/ErrorBoundary.svelte index 39ab691..e386977 100644 --- a/web-frontend/src/components/ErrorBoundary.svelte +++ b/web-frontend/src/components/ErrorBoundary.svelte @@ -19,6 +19,18 @@ const originalOnError = window.onerror; window.onerror = (message: string | Event, source?: string, line?: number, column?: number, error?: Error) => { + const isOpaqueScriptError = + message === 'Script error.' && + !source && + !line && + !column && + !error; + + if (isOpaqueScriptError) { + console.warn('Ignored opaque cross-origin script error'); + return true; + } + // Prevent error loops const now = Date.now(); if (now - lastErrorTime < 100) { diff --git a/web-frontend/src/components/JobHeader.svelte b/web-frontend/src/components/JobHeader.svelte index 6025ca2..262275e 100644 --- a/web-frontend/src/components/JobHeader.svelte +++ b/web-frontend/src/components/JobHeader.svelte @@ -127,9 +127,11 @@ } // Close dropdown when clicking outside - function handleClickOutside(event: Event) { + function handleClickOutside(event: MouseEvent) { if (showDropdown) { - const target = event.target as HTMLElement; + if (event.button !== 0 || !(event.target instanceof Element)) return; + + const target = event.target; const dropdown = target.closest('.relative'); if (!dropdown) { showDropdown = false; diff --git a/web-frontend/src/components/JobLauncher.svelte b/web-frontend/src/components/JobLauncher.svelte index 5c58394..a39bc8a 100644 --- a/web-frontend/src/components/JobLauncher.svelte +++ b/web-frontend/src/components/JobLauncher.svelte @@ -3491,10 +3491,10 @@ echo "Starting job..." position: absolute; top: calc(100% + 0.5rem); right: 0; - background: white; + background: var(--popover); border: 1px solid var(--border); border-radius: 12px; - box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); + box-shadow: 0 10px 25px color-mix(in srgb, var(--foreground) 14%, transparent); padding: 0.5rem; min-width: 250px; z-index: 50; @@ -3619,10 +3619,10 @@ echo "Starting job..." position: absolute; top: calc(100% + 0.5rem); right: 0; - background: white; + background: var(--popover); border: 1px solid var(--border); border-radius: 12px; - box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); + box-shadow: 0 10px 25px color-mix(in srgb, var(--foreground) 14%, transparent); padding: 0.75rem; min-width: 280px; max-width: calc(100vw - 2rem); @@ -3719,7 +3719,7 @@ echo "Starting job..." .option-select { padding: 0.375rem 0.5rem; font-size: 0.875rem; - background: white; + background: var(--input); border: 1px solid var(--border); border-radius: 6px; color: var(--foreground); @@ -3804,10 +3804,10 @@ echo "Starting job..." position: absolute; top: calc(100% + 0.5rem); right: 0; - background: white; + background: var(--popover); border: 1px solid var(--border); border-radius: 8px; - box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); + box-shadow: 0 10px 25px color-mix(in srgb, var(--foreground) 14%, transparent); padding: 0.5rem; min-width: 200px; z-index: 50; @@ -4288,7 +4288,7 @@ echo "Starting job..." align-items: center; gap: 0.25rem; padding: 0.375rem 0.75rem; - background: white; + background: var(--card); border: 1px solid var(--border); border-radius: 0.375rem; color: var(--muted-foreground); @@ -4346,7 +4346,7 @@ echo "Starting job..." } .var-mini { - background: white; + background: var(--input); padding: 0.25rem 0.5rem; border-radius: 0.25rem; font-size: 0.75rem; @@ -4403,7 +4403,7 @@ echo "Starting job..." align-items: center; height: 52px; padding: 0 0.75rem; - background: white; + background: var(--card); border-bottom: 1px solid var(--border); gap: 0.5rem; } @@ -4439,7 +4439,7 @@ echo "Starting job..." font-size: 0.6875rem; border: 1px solid var(--border); border-radius: 0.375rem; - background: white; + background: var(--input); color: var(--foreground); } @@ -4702,8 +4702,8 @@ echo "Starting job..." width: 380px; max-width: 90%; height: 100%; - background: white; - box-shadow: -4px 0 24px rgba(0, 0, 0, 0.1); + background: var(--popover); + box-shadow: -4px 0 24px color-mix(in srgb, var(--foreground) 16%, transparent); display: flex; flex-direction: column; } @@ -4872,7 +4872,7 @@ echo "Starting job..." align-items: center; gap: 0.75rem; padding: 0.75rem; - background: white; + background: var(--card); border: 1px solid var(--border); border-radius: 0.375rem; margin-bottom: 1rem; @@ -4932,7 +4932,7 @@ echo "Starting job..." align-items: center; justify-content: space-between; padding: 0.75rem; - background: white; + background: var(--card); border: 1px solid var(--border); border-radius: 0.375rem; margin-bottom: 0.5rem; @@ -5044,8 +5044,8 @@ echo "Starting job..." bottom: 0; width: 85%; max-width: 320px; - background: white; - box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1); + background: var(--popover); + box-shadow: -2px 0 8px color-mix(in srgb, var(--foreground) 16%, transparent); display: flex; flex-direction: column; animation: slideInRight 0.3s ease-out; @@ -5325,7 +5325,7 @@ echo "Starting job..." color: var(--muted-foreground); word-break: break-all; font-family: "SF Mono", "Monaco", "Inconsolata", "Roboto Mono", monospace; - background: white; + background: var(--input); padding: 0.5rem; border-radius: 0.375rem; } @@ -5837,7 +5837,7 @@ echo "Starting job..." } .save-template-dialog { - background: white; + background: var(--popover); border-radius: 12px; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), @@ -5885,7 +5885,7 @@ echo "Starting job..." } .template-action-btn { - background: white; + background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 0.375rem; @@ -6002,7 +6002,7 @@ echo "Starting job..." } .btn-secondary { - background: white; + background: var(--card); color: var(--foreground); border: 1px solid var(--muted); } diff --git a/web-frontend/src/components/JobSidebar.svelte b/web-frontend/src/components/JobSidebar.svelte index 40f50e7..652913c 100644 --- a/web-frontend/src/components/JobSidebar.svelte +++ b/web-frontend/src/components/JobSidebar.svelte @@ -8,6 +8,7 @@ import { debounce } from "../lib/debounce"; import { jobStateManager } from "../lib/JobStateManager"; import { jobUtils } from "../lib/jobUtils"; + import { safeGetItem, safeSetItem } from "../lib/safeStorage"; import { navigationActions } from "../stores/navigation"; import { preferences, preferencesActions } from "../stores/preferences"; import { sidebarOpen } from "../stores/sidebar"; @@ -41,6 +42,8 @@ const runningJobs = jobStateManager.getJobsByState('R'); const pendingJobs = jobStateManager.getJobsByState('PD'); const managerState = jobStateManager.getState(); + const hostStates = jobStateManager.getHostStates(); + const HOST_FILTER_STORAGE_KEY = "sidebar-excluded-hosts"; // Derive hasArrayJobGrouping from arrayJobGroups store import { derived } from "svelte/store"; @@ -63,6 +66,8 @@ let searchInputValue = $state(""); let searchQuery = $state(""); let searchFocused = $state(false); + let excludedHosts = $state>(new Set()); + let showHostFilter = $state(false); // Component state let loading = $state(false); @@ -75,6 +80,103 @@ let filteredArrayGroups = $state([]); let arrayTaskIds = new Set(); + type HostOption = { + hostname: string; + total: number; + running: number; + pending: number; + recent: number; + }; + + let hostOptions = $derived.by(() => { + const hosts = new Map(); + + function ensureHost(hostname: string | undefined | null): HostOption | null { + if (!hostname) return null; + let host = hosts.get(hostname); + if (!host) { + host = { + hostname, + total: 0, + running: 0, + pending: 0, + recent: 0, + }; + hosts.set(hostname, host); + } + return host; + } + + $hostStates.forEach((_, hostname) => { + ensureHost(hostname); + }); + + $allJobs.forEach((job) => { + const host = ensureHost(job.hostname); + if (!host) return; + host.total += 1; + if (job.state === "R") host.running += 1; + if (job.state === "PD") host.pending += 1; + if (job.state && jobUtils.isTerminalState(job.state)) host.recent += 1; + }); + + return Array.from(hosts.values()).sort((a, b) => + a.hostname.localeCompare(b.hostname), + ); + }); + + let selectedHostCount = $derived( + hostOptions.filter((host) => !excludedHosts.has(host.hostname)).length, + ); + let hiddenHostCount = $derived(hostOptions.length - selectedHostCount); + let hasHostFilter = $derived(hiddenHostCount > 0); + + function persistHostFilter(nextExcludedHosts: Set) { + safeSetItem(HOST_FILTER_STORAGE_KEY, JSON.stringify(Array.from(nextExcludedHosts))); + } + + function isHostSelected(hostname: string): boolean { + return !excludedHosts.has(hostname); + } + + function toggleHost(hostname: string) { + const nextExcludedHosts = new Set(excludedHosts); + if (nextExcludedHosts.has(hostname)) { + nextExcludedHosts.delete(hostname); + } else { + nextExcludedHosts.add(hostname); + } + excludedHosts = nextExcludedHosts; + persistHostFilter(nextExcludedHosts); + } + + function selectAllHosts() { + excludedHosts = new Set(); + persistHostFilter(excludedHosts); + } + + function selectNoHosts() { + const nextExcludedHosts = new Set(hostOptions.map((host) => host.hostname)); + excludedHosts = nextExcludedHosts; + persistHostFilter(nextExcludedHosts); + } + + function invertHostSelection() { + const nextExcludedHosts = new Set(); + hostOptions.forEach((host) => { + if (isHostSelected(host.hostname)) { + nextExcludedHosts.add(host.hostname); + } + }); + excludedHosts = nextExcludedHosts; + persistHostFilter(nextExcludedHosts); + } + + function filterBySelectedHosts(items: T[]): T[] { + if (excludedHosts.size === 0) return items; + return items.filter((item) => !excludedHosts.has(item.hostname)); + } + // Track which jobs are part of array groups $effect(() => { arrayTaskIds.clear(); @@ -152,9 +254,9 @@ // Use $effect to handle filtering synchronously (no requestAnimationFrame needed) $effect(() => { - const baseRunning = filterOutArrayTasks($runningJobs); - const basePending = filterOutArrayTasks($pendingJobs); - const baseRecent = filterOutArrayTasks(recentJobs); + const baseRunning = filterBySelectedHosts(filterOutArrayTasks($runningJobs)); + const basePending = filterBySelectedHosts(filterOutArrayTasks($pendingJobs)); + const baseRecent = filterBySelectedHosts(filterOutArrayTasks(recentJobs)); const limitCount = $preferences.jobsPerPage ?? 50; const limitedRecent = searchQuery ? baseRecent : baseRecent.slice(0, limitCount); @@ -178,8 +280,10 @@ ) { filteredArrayGroups = $arrayJobGroups.filter( (group) => - group.array_job_id.toLowerCase().includes(query) || - group.job_name.toLowerCase().includes(query), + !excludedHosts.has(group.hostname) && + (group.array_job_id.toLowerCase().includes(query) || + group.job_name.toLowerCase().includes(query) || + group.hostname.toLowerCase().includes(query)), ); } else { filteredArrayGroups = []; @@ -190,7 +294,9 @@ filteredRecentJobs = limitedRecent; // Only populate array groups when grouping is enabled filteredArrayGroups = - $preferences.groupArrayJobs && $arrayJobGroups ? $arrayJobGroups : []; + $preferences.groupArrayJobs && $arrayJobGroups + ? $arrayJobGroups.filter((group) => !excludedHosts.has(group.hostname)) + : []; } }); @@ -346,6 +452,17 @@ onMount(() => { // JobStateManager automatically handles initial load + try { + const savedExcludedHosts = safeGetItem(HOST_FILTER_STORAGE_KEY); + if (savedExcludedHosts) { + const parsed = JSON.parse(savedExcludedHosts); + if (Array.isArray(parsed)) { + excludedHosts = new Set(parsed.filter((host) => typeof host === "string")); + } + } + } catch (error) { + console.warn("Failed to load sidebar host filter:", error); + } }); onDestroy(() => { @@ -384,6 +501,26 @@ +
+ {#each hostOptions as host (host.hostname)} + + {/each} +
+ + {/if} + {#if !actuallyCollapsed} {/if} + {#if !searchQuery && !hasSearchResults && $allJobs.length > 0 && selectedHostCount === 0} +
+ + + +

No hosts selected

+ +
+ {/if} + + {#if !searchQuery && !hasSearchResults && $allJobs.length > 0 && selectedHostCount > 0} +
+ + + +

No jobs on selected hosts

+ +
+ {/if} + {#if $allJobs.length === 0}
@@ -1267,6 +1462,34 @@ position: relative; } + .host-filter-toggle { + position: relative; + } + + .host-filter-badge { + position: absolute; + top: -4px; + right: -4px; + min-width: 14px; + height: 14px; + padding: 0 3px; + border-radius: 999px; + background: var(--accent); + color: white; + font-size: 0.6rem; + font-weight: 700; + line-height: 14px; + } + + .icon-btn.active { + background: var(--accent); + color: white; + } + + .icon-btn.active:hover { + background: color-mix(in srgb, var(--accent) 90%, black); + } + .search-toggle.active { background: var(--accent); color: white; @@ -1348,6 +1571,115 @@ height: 14px; } + .host-filter-panel { + padding: 0.5rem 0.75rem 0.75rem; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--secondary) 96%, var(--foreground) 4%); + } + + .host-filter-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.5rem; + color: var(--muted-foreground); + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + } + + .host-filter-actions { + display: flex; + align-items: center; + gap: 0.25rem; + } + + .host-filter-actions button { + height: 24px; + min-width: 36px; + padding: 0 0.5rem; + border: 1px solid var(--border); + border-radius: 0.25rem; + background: var(--secondary); + color: var(--foreground); + cursor: pointer; + font-size: 0.68rem; + font-weight: 700; + } + + .host-filter-actions button:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); + } + + .host-filter-actions button:disabled { + opacity: 0.45; + cursor: not-allowed; + } + + .host-chip-list { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + } + + .host-chip { + display: inline-flex; + align-items: center; + max-width: 100%; + height: 28px; + padding: 0 0.35rem 0 0.5rem; + gap: 0.35rem; + border: 1px solid var(--border); + border-radius: 0.375rem; + background: var(--secondary); + color: var(--muted-foreground); + cursor: pointer; + font-size: 0.75rem; + font-weight: 700; + transition: + border-color 0.15s ease, + background 0.15s ease, + color 0.15s ease, + opacity 0.15s ease; + } + + .host-chip:hover { + border-color: var(--accent); + color: var(--foreground); + } + + .host-chip.active { + border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); + background: color-mix(in srgb, var(--accent) 12%, var(--secondary)); + color: var(--foreground); + } + + .host-chip:not(.active) { + background: color-mix(in srgb, var(--secondary) 92%, var(--foreground) 8%); + opacity: 0.62; + } + + .host-chip-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 132px; + } + + .host-chip-count { + min-width: 18px; + height: 18px; + padding: 0 0.25rem; + border-radius: 999px; + background: var(--muted); + color: var(--foreground); + font-size: 0.65rem; + line-height: 18px; + text-align: center; + } + /* No search results state */ .no-search-results { display: flex; @@ -1394,6 +1726,14 @@ padding: 0 0.5rem; } + .job-sidebar.mobile .host-filter-panel { + padding: 0.5rem; + } + + .job-sidebar.mobile .host-chip-name { + max-width: 108px; + } + .job-sidebar.mobile .sidebar-search-input { font-size: 0.875rem; } diff --git a/web-frontend/src/components/JobTable.svelte b/web-frontend/src/components/JobTable.svelte index 53c54bd..34968ca 100644 --- a/web-frontend/src/components/JobTable.svelte +++ b/web-frontend/src/components/JobTable.svelte @@ -126,7 +126,9 @@ // Close dropdowns on outside click function handleClickOutside(event: MouseEvent) { - const target = event.target as HTMLElement; + if (event.button !== 0 || !(event.target instanceof Element)) return; + + const target = event.target; // Only close dropdowns if clicking outside ALL filter dropdowns, menus, and sortable headers if (!target.closest('.filter-dropdown') && !target.closest('.dropdown-menu') && diff --git a/web-frontend/src/components/OutputViewer.svelte b/web-frontend/src/components/OutputViewer.svelte index 4ae6568..8f94c0b 100644 --- a/web-frontend/src/components/OutputViewer.svelte +++ b/web-frontend/src/components/OutputViewer.svelte @@ -394,9 +394,11 @@ showSettingsMenu = false; } - function handleClickOutside(event: Event) { + function handleClickOutside(event: MouseEvent) { if (showSettingsMenu) { - const target = event.target as HTMLElement; + if (event.button !== 0 || !(event.target instanceof Element)) return; + + const target = event.target; if (!target.closest('.settings-dropdown')) { showSettingsMenu = false; } diff --git a/web-frontend/src/components/SaveTemplateDialog.svelte b/web-frontend/src/components/SaveTemplateDialog.svelte index e1d6fba..f4d220d 100644 --- a/web-frontend/src/components/SaveTemplateDialog.svelte +++ b/web-frontend/src/components/SaveTemplateDialog.svelte @@ -185,10 +185,17 @@ padding: 0.5rem 0.75rem; border: 1px solid var(--border); border-radius: 6px; + background: var(--input); + color: var(--foreground); font-size: 0.875rem; transition: all 0.2s; } + .form-input::placeholder, + .form-textarea::placeholder { + color: var(--muted-foreground); + } + .form-input:focus, .form-textarea:focus { outline: none; diff --git a/web-frontend/src/components/ScriptViewer.svelte b/web-frontend/src/components/ScriptViewer.svelte index ef789c6..2a377cf 100644 --- a/web-frontend/src/components/ScriptViewer.svelte +++ b/web-frontend/src/components/ScriptViewer.svelte @@ -459,9 +459,11 @@ showSettingsMenu = false; } - function handleClickOutside(event: Event) { + function handleClickOutside(event: MouseEvent) { if (showSettingsMenu) { - const target = event.target as HTMLElement; + if (event.button !== 0 || !(event.target instanceof Element)) return; + + const target = event.target; if (!target.closest('.settings-dropdown')) { showSettingsMenu = false; } diff --git a/web-frontend/src/components/WatchersTab.svelte b/web-frontend/src/components/WatchersTab.svelte index e1f4674..f113714 100644 --- a/web-frontend/src/components/WatchersTab.svelte +++ b/web-frontend/src/components/WatchersTab.svelte @@ -1,6 +1,7 @@ @@ -242,6 +293,7 @@ {#each jobWatchers as watcher} {#if watcher} {@const SvelteComponent = getStateIcon(watcher.state)} + {@const watcherEventsForCard = eventsForWatcher(watcher.id)}
+
+

Pattern

@@ -373,6 +436,79 @@ {/if}
+ +
+

+ + Recent Events +

+ + {#if watcherEventsForCard.length === 0} +

No events recorded for this watcher yet

+ {:else} +
+ {#each watcherEventsForCard as event (event.id)} + {@const eventExpanded = expandedEvents.has(event.id)} +
+ + + {#if eventExpanded} +
+
+ +
+ + {#if event.matched_text} +
+ Matched text + {event.matched_text} +
+ {/if} + + {#if event.captured_vars && Object.keys(event.captured_vars).length > 0} +
+ Captured variables +
+ {#each Object.entries(event.captured_vars) as [name, value]} +
+ {name}: + {value} +
+ {/each} +
+
+ {/if} + + {#if event.action_result} +
+ Action result +
{event.action_result}
+
+ {/if} + + +
+ {/if} +
+ {/each} +
+ {/if} +
{/if} @@ -382,6 +518,73 @@ {/if} +{#if selectedEvent} + +
+
+
+ Watcher + {selectedEventWatcher?.name || selectedEvent.watcher_name || `#${selectedEvent.watcher_id}`} +
+
+ Job + #{selectedEvent.job_id} on {selectedEvent.hostname} +
+
+ Action + {selectedEvent.action_type.replace(/_/g, ' ')} +
+
+ Status + + {selectedEvent.success ? 'success' : 'failed'} + +
+
+ + {#if selectedEvent.matched_text} +
+

Matched Text

+
{selectedEvent.matched_text}
+
+ {/if} + + {#if selectedEvent.captured_vars && Object.keys(selectedEvent.captured_vars).length > 0} +
+

Captured Variables

+
+ {#each Object.entries(selectedEvent.captured_vars) as [name, value]} +
+ {name} + {value} +
+ {/each} +
+
+ {/if} + + {#if selectedEvent.action_result} +
+

Action Result

+
{selectedEvent.action_result}
+
+ {/if} + +
+

Raw Event

+
{JSON.stringify(selectedEvent, null, 2)}
+
+
+
+{/if} + diff --git a/web-frontend/src/global.css b/web-frontend/src/global.css index 0d90c44..63621f1 100644 --- a/web-frontend/src/global.css +++ b/web-frontend/src/global.css @@ -141,10 +141,14 @@ .dark .cm-editor, .dark .cm-content, .dark .cm-gutters { - background: var(--input) !important; + background: #171717 !important; color: var(--foreground) !important; } +.dark .cm-gutters { + background: #1f1f1f !important; +} + /* Select/datalist options - Keep these as they're hard to style otherwise */ .dark select option, .dark datalist option { diff --git a/web-frontend/src/lib/actions/clickOutside.ts b/web-frontend/src/lib/actions/clickOutside.ts index 1c7b38d..caf3b55 100644 --- a/web-frontend/src/lib/actions/clickOutside.ts +++ b/web-frontend/src/lib/actions/clickOutside.ts @@ -31,8 +31,10 @@ export function clickOutside(node: HTMLElement, options: ClickOutsideOptions = { const handleClick = (event: MouseEvent) => { if (!currentEnabled) return; + if (event.button !== 0) return; + if (!(event.target instanceof Node)) return; - const target = event.target as Node; + const target = event.target; // Check if click is inside the element if (node.contains(target)) return; @@ -70,4 +72,4 @@ export function clickOutside(node: HTMLElement, options: ClickOutsideOptions = { document.removeEventListener('click', handleClick, true); } }; -} \ No newline at end of file +} diff --git a/web-frontend/src/lib/components/ui/Dialog.svelte b/web-frontend/src/lib/components/ui/Dialog.svelte index 58627fb..fc85030 100644 --- a/web-frontend/src/lib/components/ui/Dialog.svelte +++ b/web-frontend/src/lib/components/ui/Dialog.svelte @@ -119,10 +119,13 @@ \ No newline at end of file + diff --git a/web-frontend/src/pages/JobPage.svelte b/web-frontend/src/pages/JobPage.svelte index 27936c4..11a453b 100644 --- a/web-frontend/src/pages/JobPage.svelte +++ b/web-frontend/src/pages/JobPage.svelte @@ -57,6 +57,7 @@ let currentOutputType: OutputStreamType | null = $state(null); let outputStreamSession: OutputStreamSession | null = null; let outputRequestVersion = 0; + const OUTPUT_RETRY_DELAYS_MS = [1500, 2500, 4000, 6000, 8000, 10000, 15000, 20000]; // Script related state let scriptData: ScriptData | null = $state(null); @@ -231,15 +232,16 @@ } function scheduleOutputRetry(outputType: OutputStreamType) { - if (outputBackgroundRetryCount >= 2) return; + if (outputBackgroundRetryCount >= OUTPUT_RETRY_DELAYS_MS.length) return; clearOutputRetryTimer(); + const retryDelay = OUTPUT_RETRY_DELAYS_MS[outputBackgroundRetryCount]; outputRetryTimer = setTimeout(() => { if (getActiveOutputType() !== outputType) { return; } outputBackgroundRetryCount += 1; void loadOutput(outputType, { backgroundRetry: true }); - }, 1200); + }, retryDelay); } async function loadOutput( @@ -264,11 +266,10 @@ try { if (job.state === 'R') { - const metadataResponse = await api.get(`/api/jobs/${params.id}/output`, { + const cachedResponse = await api.get(`/api/jobs/${params.id}/output`, { params: { host: params.host, output_type: outputType, - metadata_only: true, max_bytes: DEFAULT_OUTPUT_MAX_BYTES, force_refresh: options.forceRefresh ? 'true' : undefined, }, @@ -278,9 +279,12 @@ return; } - outputData = mergeOutputData(outputType, metadataResponse.data); + outputData = mergeOutputData(outputType, cachedResponse.data); loadingOutput = false; refreshingOutput = false; + const cachedContent = + (outputType === 'stdout' ? outputData.stdout : outputData.stderr) || ''; + const streamInitialBytes = cachedContent ? 1024 : DEFAULT_OUTPUT_MAX_BYTES; outputStreamSession = streamJobOutput( params.id, @@ -338,12 +342,17 @@ return; } stopOutputStream(); - outputError = message; + const currentContent = ( + outputType === 'stdout' ? outputData?.stdout : outputData?.stderr + ) || ''; + if (!currentContent) { + outputError = message; + } loadingOutput = false; refreshingOutput = false; }, }, - DEFAULT_OUTPUT_MAX_BYTES, + streamInitialBytes, ); } else { const response = await api.get(`/api/jobs/${params.id}/output`, { @@ -360,7 +369,11 @@ } outputData = response.data; - if (response.data.refresh_queued) { + const receivedContent = + outputType === 'stdout' + ? Boolean(response.data.stdout) + : Boolean(response.data.stderr); + if (response.data.refresh_queued && !receivedContent) { scheduleOutputRetry(outputType); } else { clearOutputRetryTimer(); diff --git a/web-frontend/src/pages/JobsPage.svelte b/web-frontend/src/pages/JobsPage.svelte index a59e015..3c00de4 100644 --- a/web-frontend/src/pages/JobsPage.svelte +++ b/web-frontend/src/pages/JobsPage.svelte @@ -15,7 +15,13 @@ import { navigationActions } from "../stores/navigation"; import { preferences } from "../stores/preferences"; import { fetchAllWatchers } from "../stores/watchers"; - import type { HostInfo, JobFilters, JobInfo, PartitionStatusResponse } from "../types/api"; + import type { + HostInfo, + JobFilters, + JobInfo, + PartitionResources, + PartitionStatusResponse, + } from "../types/api"; let hosts: HostInfo[] = $state([]); let loading = $state(false); @@ -277,6 +283,41 @@ return new Date(value).toLocaleDateString(); } + function percent(value: number | null | undefined, total: number | null | undefined): number { + if (!value || !total || total <= 0) return 0; + return Math.max(0, Math.min(100, Math.round((value / total) * 100))); + } + + function formatNumber(value: number | null | undefined): string { + return value === null || value === undefined ? "-" : value.toLocaleString(); + } + + function gpuLabel(partition: PartitionResources): string { + if (partition.gpus_total === null) return "Unknown"; + if (partition.gpus_total === 0) return "No GPUs"; + if (partition.gpus_used === null) return `Unknown used of ${partition.gpus_total}`; + return `${partition.gpus_used} used of ${partition.gpus_total}`; + } + + function availabilityVariant(value: string | null): "default" | "secondary" | "warning" | "destructive" { + const normalized = (value || "").toLowerCase(); + if (!normalized || normalized === "unknown") return "secondary"; + if (normalized.includes("up") || normalized.includes("available")) return "default"; + if (normalized.includes("down") || normalized.includes("drain")) return "destructive"; + return "warning"; + } + + function partitionHealth(partition: PartitionResources): "available" | "busy" | "limited" | "offline" { + const availability = (partition.availability || "").toLowerCase(); + const states = (partition.states || []).join(" ").toLowerCase(); + if (availability.includes("down") || states.includes("down") || states.includes("drain")) { + return "offline"; + } + if ((partition.cpus_idle ?? 0) > 0) return "available"; + if ((partition.cpus_alloc ?? 0) > 0) return "busy"; + return "limited"; + } + async function loadHosts(): Promise { if (hostsLoading) return; @@ -735,7 +776,7 @@ {:else}
{#each partitionStates as hostState (hostState.hostname)} -
+
{:else} -
- +
+
- - - - - - - + + + + + + + {#each hostState.partitions as partition (partition.partition)} - - + - - - - - {/each} @@ -865,4 +958,168 @@ transform: rotate(360deg); } } + + .partition-host-card { + border: 1px solid var(--border); + border-radius: 0.5rem; + background: var(--background); + padding: 0.75rem; + } + + .partition-table-wrap { + overflow-x: auto; + } + + .partition-table { + width: 100%; + min-width: 860px; + border-collapse: separate; + border-spacing: 0; + font-size: 0.875rem; + } + + .partition-table th { + padding: 0.5rem 1rem 0.5rem 0; + text-align: left; + color: var(--muted-foreground); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + border-bottom: 1px solid var(--border); + } + + .partition-table td { + padding: 0.85rem 1rem 0.85rem 0; + vertical-align: top; + border-bottom: 1px solid color-mix(in srgb, var(--border) 65%, transparent); + } + + .partition-table tr:last-child td { + border-bottom: 0; + } + + .partition-name { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 700; + color: var(--foreground); + } + + .resource-cell { + min-width: 180px; + display: grid; + gap: 0.4rem; + } + + .resource-line { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.75rem; + } + + .resource-primary { + color: var(--foreground); + font-weight: 650; + } + + .resource-secondary, + .resource-note { + color: var(--muted-foreground); + font-size: 0.75rem; + } + + .resource-meter { + height: 0.45rem; + overflow: hidden; + border-radius: 999px; + background: var(--muted); + } + + .resource-meter-fill { + height: 100%; + border-radius: inherit; + } + + .resource-meter-fill.cpu { + background: #2563eb; + } + + .resource-meter-fill.gpu { + background: #7c3aed; + } + + .state-chip-list { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + } + + .state-chip { + border: 1px solid var(--border); + border-radius: 0.25rem; + background: var(--muted); + color: var(--muted-foreground); + padding: 0.15rem 0.45rem; + font-size: 0.72rem; + font-weight: 650; + } + + @media (max-width: 767px) { + .partition-table-wrap { + overflow: visible; + } + + .partition-table, + .partition-table thead, + .partition-table tbody, + .partition-table tr, + .partition-table th, + .partition-table td { + display: block; + min-width: 0; + width: 100%; + } + + .partition-table thead { + display: none; + } + + .partition-table tr { + border: 1px solid var(--border); + border-radius: 0.5rem; + padding: 0.65rem; + margin-bottom: 0.75rem; + background: color-mix(in srgb, var(--background) 96%, var(--foreground) 4%); + } + + .partition-table tr:last-child { + margin-bottom: 0; + } + + .partition-table td { + display: grid; + grid-template-columns: 7rem minmax(0, 1fr); + gap: 0.75rem; + padding: 0.45rem 0; + border-bottom: 0; + } + + .partition-table td::before { + content: attr(data-label); + color: var(--muted-foreground); + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + } + + .resource-line { + align-items: flex-start; + flex-direction: column; + gap: 0.15rem; + } + }
PartitionAvailCPUs a/i/tGPUs u/tNodesState
PartitionAvailabilityCPU CapacityGPU CapacityNodesNode States
- {partition.partition} + {@const cpuUsedPercent = percent(partition.cpus_alloc, partition.cpus_total)} + {@const cpuIdlePercent = percent(partition.cpus_idle, partition.cpus_total)} + {@const gpuUsedPercent = percent(partition.gpus_used, partition.gpus_total)} + {@const health = partitionHealth(partition)} +
+
+ + + {partition.partition} + +
- {partition.availability || "-"} + + + {partition.availability || "Unknown"} + - - {partition.cpus_alloc}/{partition.cpus_idle}/{partition.cpus_total} - + +
+
+ + {formatNumber(partition.cpus_alloc)} used + + + {formatNumber(partition.cpus_idle)} idle · {formatNumber(partition.cpus_total)} total + +
+
+
+
+
+ {cpuUsedPercent}% allocated, {cpuIdlePercent}% idle +
+
- {#if partition.gpus_total === null} - - - {:else if partition.gpus_total === 0} - 0 - {:else if partition.gpus_used === null} - ?/{partition.gpus_total} - {:else} - {partition.gpus_used}/{partition.gpus_total} - {/if} + +
+
+ {gpuLabel(partition)} +
+ {#if partition.gpus_total !== null && partition.gpus_total > 0 && partition.gpus_used !== null} +
+
+
+
+ {gpuUsedPercent}% allocated +
+ {:else} +
+ GPU allocation not reported +
+ {/if} +
- {partition.nodes_total} + + + {formatNumber(partition.nodes_total)} + - {(partition.states || []).join(", ") || "-"} + +
+ {#if partition.states && partition.states.length > 0} + {#each partition.states as state} + + {state} + + {/each} + {:else} + - + {/if} +