Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
41 changes: 41 additions & 0 deletions mobile-app/.gitignore
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions mobile-app/.vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "recommendations": ["expo.vscode-expo-tools"] }
7 changes: 7 additions & 0 deletions mobile-app/.vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
}
}
42 changes: 42 additions & 0 deletions mobile-app/app.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
56 changes: 56 additions & 0 deletions mobile-app/app/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof FontAwesome>['name'];
color: string;
}) {
return <FontAwesome size={28} style={{ marginBottom: -3 }} {...props} />;
}

export default function TabLayout() {
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: colors.primary,
tabBarInactiveTintColor: colors.textDim,
tabBarStyle: {
backgroundColor: colors.surface,
borderTopColor: colors.border,
},
}}>
<Tabs.Screen
name="jobs"
options={{
title: 'Jobs',
tabBarIcon: ({ color }) => <TabBarIcon name="dashboard" color={color} />,
}}
/>
<Tabs.Screen
name="launch"
options={{
title: 'Launch',
tabBarIcon: ({ color }) => <TabBarIcon name="rocket" color={color} />,
}}
/>
<Tabs.Screen
name="watchers"
options={{
title: 'Watchers',
tabBarIcon: ({ color }) => <TabBarIcon name="eye" color={color} />,
}}
/>
<Tabs.Screen
name="settings"
options={{
title: 'Settings',
tabBarIcon: ({ color }) => <TabBarIcon name="sliders" color={color} />,
}}
/>
</Tabs>
);
}
123 changes: 123 additions & 0 deletions mobile-app/app/(tabs)/jobs.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Screen
eyebrow="Ops Deck"
title="Live Slurm jobs"
subtitle="Track jobs across clusters, keep launch feedback visible, and fall back to polling cleanly when realtime drops."
headerRight={
<PrimaryButton
label="Reload"
tone="ghost"
onPress={() => {
query.refetch();
}}
/>
}
>
<ConnectionBanner
source={connection.source}
websocketHealthy={connection.websocketHealthy}
lastSyncAt={connection.lastSyncAt}
error={connection.lastError}
/>

{!baseUrl ? (
<EmptyState
title="Connect the app first"
body="Add the ssync server URL and API key in Settings before loading jobs."
/>
) : null}

<SectionCard title="Filter" subtitle="Keep the query cheap, then use local search for quick narrowing.">
<TextField label="Search" value={search} onChangeText={setSearch} placeholder="job id, name, host" />
</SectionCard>

<SectionCard title="Jobs" subtitle={`${items.length} visible items`} action={query.isFetching ? <ActivityIndicator color={colors.primary} /> : null}>
{query.isLoading ? (
<ActivityIndicator color={colors.primary} />
) : items.length === 0 ? (
<EmptyState
title="No matching jobs"
body="Try broadening the search or verify that the server is reachable."
/>
) : (
<View style={styles.listContainer}>
<FlatList
data={items}
keyExtractor={(item) => `${item.hostname}:${item.job_id}`}
renderItem={({ item }) => (
<JobCard
job={item}
onPress={() =>
router.push({
pathname: '/job/[jobId]',
params: { jobId: item.job_id, hostname: item.hostname },
})
}
/>
)}
ItemSeparatorComponent={() => <View style={{ height: spacing.sm }} />}
/>
</View>
)}
</SectionCard>

{query.error ? <Text style={styles.error}>{`${query.error}`}</Text> : null}
</Screen>
);
}

const styles = StyleSheet.create({
listContainer: {
minHeight: 440,
},
error: {
color: colors.danger,
},
});
Loading