mirror of
https://github.com/portainer/portainer.git
synced 2025-08-05 05:45:22 +02:00
refactor(nomad): sync frontend with EE [EE-3353] (#7758)
This commit is contained in:
parent
78dcba614d
commit
881e99df53
68 changed files with 1799 additions and 17 deletions
11
app/react/constants.ts
Normal file
11
app/react/constants.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
export const BROWSER_OS_PLATFORM = getOs();
|
||||
|
||||
function getOs() {
|
||||
const { userAgent } = navigator;
|
||||
|
||||
if (userAgent.includes('Windows')) {
|
||||
return 'win';
|
||||
}
|
||||
|
||||
return userAgent.includes('Mac') ? 'mac' : 'lin';
|
||||
}
|
83
app/react/nomad/DashboardView/DashboardView.tsx
Normal file
83
app/react/nomad/DashboardView/DashboardView.tsx
Normal file
|
@ -0,0 +1,83 @@
|
|||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
|
||||
import { DashboardItem } from '@@/DashboardItem';
|
||||
import { Widget, WidgetTitle, WidgetBody } from '@@/Widget';
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
import { DashboardGrid } from '@@/DashboardItem/DashboardGrid';
|
||||
|
||||
import { useDashboard } from './useDashboard';
|
||||
import { RunningStatus } from './RunningStatus';
|
||||
|
||||
export function DashboardView() {
|
||||
const environmentId = useEnvironmentId();
|
||||
const dashboardQuery = useDashboard(environmentId);
|
||||
|
||||
const running = dashboardQuery.data?.RunningTaskCount || 0;
|
||||
const stopped = (dashboardQuery.data?.TaskCount || 0) - running;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
breadcrumbs={[{ label: 'Environment summary' }]}
|
||||
/>
|
||||
|
||||
{dashboardQuery.isLoading ? (
|
||||
<div className="text-center" style={{ marginTop: '30%' }}>
|
||||
Connecting to the Edge environment...
|
||||
<i className="fa fa-cog fa-spin space-left" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
{/* cluster info */}
|
||||
<Widget>
|
||||
<WidgetTitle
|
||||
icon="fa-tachometer-alt"
|
||||
title="Cluster information"
|
||||
/>
|
||||
<WidgetBody className="no-padding">
|
||||
<table className="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Nodes in the cluster</td>
|
||||
<td>{dashboardQuery.data?.NodeCount ?? '-'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-4">
|
||||
<DashboardGrid>
|
||||
{/* jobs */}
|
||||
<DashboardItem
|
||||
value={dashboardQuery.data?.JobCount}
|
||||
icon="fa fa-th-list"
|
||||
type="Nomad Job"
|
||||
/>
|
||||
{/* groups */}
|
||||
<DashboardItem
|
||||
value={dashboardQuery.data?.GroupCount}
|
||||
icon="fa fa-list-alt"
|
||||
type="Group"
|
||||
/>
|
||||
{/* tasks */}
|
||||
<DashboardItem
|
||||
value={dashboardQuery.data?.TaskCount}
|
||||
icon="fa fa-cubes"
|
||||
type="Task"
|
||||
>
|
||||
{/* running status of tasks */}
|
||||
<RunningStatus running={running} stopped={stopped} />
|
||||
</DashboardItem>
|
||||
</DashboardGrid>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
25
app/react/nomad/DashboardView/RunningStatus.tsx
Normal file
25
app/react/nomad/DashboardView/RunningStatus.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
interface Props {
|
||||
running: number;
|
||||
stopped: number;
|
||||
}
|
||||
|
||||
export function RunningStatus({ running, stopped }: Props) {
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<i
|
||||
className="fa fa-power-off green-icon space-right"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{`${running || '-'} running`}
|
||||
</div>
|
||||
<div>
|
||||
<i
|
||||
className="fa fa-power-off red-icon space-right"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{`${stopped || '-'} stopped`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
1
app/react/nomad/DashboardView/index.ts
Normal file
1
app/react/nomad/DashboardView/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { DashboardView } from './DashboardView';
|
41
app/react/nomad/DashboardView/useDashboard.ts
Normal file
41
app/react/nomad/DashboardView/useDashboard.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
export type DashboardResponse = {
|
||||
JobCount: number;
|
||||
GroupCount: number;
|
||||
TaskCount: number;
|
||||
RunningTaskCount: number;
|
||||
NodeCount: number;
|
||||
};
|
||||
|
||||
export function useDashboard(environmentId: EnvironmentId) {
|
||||
return useQuery(
|
||||
['environments', environmentId, 'nomad', 'dashboard'],
|
||||
() => getDashboard(environmentId),
|
||||
{
|
||||
meta: {
|
||||
error: {
|
||||
title: 'Failure',
|
||||
message: 'Unable to get dashboard information',
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function getDashboard(environmentId: EnvironmentId) {
|
||||
try {
|
||||
const { data: dashboard } = await axios.get<DashboardResponse>(
|
||||
`/nomad/endpoints/${environmentId}/dashboard`,
|
||||
{
|
||||
params: {},
|
||||
}
|
||||
);
|
||||
return dashboard;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,163 @@
|
|||
import { Fragment, useEffect } from 'react';
|
||||
import {
|
||||
useFilters,
|
||||
useGlobalFilter,
|
||||
usePagination,
|
||||
useSortBy,
|
||||
useTable,
|
||||
} from 'react-table';
|
||||
|
||||
import { NomadEvent } from '@/react/nomad/types';
|
||||
import { useDebounce } from '@/react/hooks/useDebounce';
|
||||
|
||||
import { PaginationControls } from '@@/PaginationControls';
|
||||
import {
|
||||
Table,
|
||||
TableContainer,
|
||||
TableHeaderRow,
|
||||
TableRow,
|
||||
TableTitle,
|
||||
} from '@@/datatables';
|
||||
import { multiple } from '@@/datatables/filter-types';
|
||||
import { useTableSettings } from '@@/datatables/useTableSettings';
|
||||
import { SearchBar, useSearchBarState } from '@@/datatables/SearchBar';
|
||||
import { TableFooter } from '@@/datatables/TableFooter';
|
||||
import { TableContent } from '@@/datatables/TableContent';
|
||||
|
||||
import { useColumns } from './columns';
|
||||
|
||||
export interface EventsDatatableProps {
|
||||
data: NomadEvent[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export interface EventsTableSettings {
|
||||
autoRefreshRate: number;
|
||||
pageSize: number;
|
||||
sortBy: { id: string; desc: boolean };
|
||||
}
|
||||
|
||||
export function EventsDatatable({ data, isLoading }: EventsDatatableProps) {
|
||||
const { settings, setTableSettings } =
|
||||
useTableSettings<EventsTableSettings>();
|
||||
const [searchBarValue, setSearchBarValue] = useSearchBarState('events');
|
||||
const columns = useColumns();
|
||||
const debouncedSearchValue = useDebounce(searchBarValue);
|
||||
|
||||
const {
|
||||
getTableProps,
|
||||
getTableBodyProps,
|
||||
headerGroups,
|
||||
page,
|
||||
prepareRow,
|
||||
gotoPage,
|
||||
setPageSize,
|
||||
setGlobalFilter,
|
||||
state: { pageIndex, pageSize },
|
||||
} = useTable<NomadEvent>(
|
||||
{
|
||||
defaultCanFilter: false,
|
||||
columns,
|
||||
data,
|
||||
filterTypes: { multiple },
|
||||
initialState: {
|
||||
pageSize: settings.pageSize || 10,
|
||||
sortBy: [settings.sortBy],
|
||||
globalFilter: searchBarValue,
|
||||
},
|
||||
},
|
||||
useFilters,
|
||||
useGlobalFilter,
|
||||
useSortBy,
|
||||
usePagination
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setGlobalFilter(debouncedSearchValue);
|
||||
}, [debouncedSearchValue, setGlobalFilter]);
|
||||
|
||||
const tableProps = getTableProps();
|
||||
const tbodyProps = getTableBodyProps();
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<TableTitle icon="fa-history" label="Events" />
|
||||
|
||||
<SearchBar value={searchBarValue} onChange={handleSearchBarChange} />
|
||||
|
||||
<Table
|
||||
className={tableProps.className}
|
||||
role={tableProps.role}
|
||||
style={tableProps.style}
|
||||
>
|
||||
<thead>
|
||||
{headerGroups.map((headerGroup) => {
|
||||
const { key, className, role, style } =
|
||||
headerGroup.getHeaderGroupProps();
|
||||
|
||||
return (
|
||||
<TableHeaderRow<NomadEvent>
|
||||
key={key}
|
||||
className={className}
|
||||
role={role}
|
||||
style={style}
|
||||
headers={headerGroup.headers}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</thead>
|
||||
<tbody
|
||||
className={tbodyProps.className}
|
||||
role={tbodyProps.role}
|
||||
style={tbodyProps.style}
|
||||
>
|
||||
<TableContent
|
||||
rows={page}
|
||||
prepareRow={prepareRow}
|
||||
isLoading={isLoading}
|
||||
emptyContent="No events found"
|
||||
renderRow={(row, { key, className, role, style }) => (
|
||||
<Fragment key={key}>
|
||||
<TableRow<NomadEvent>
|
||||
cells={row.cells}
|
||||
key={key}
|
||||
className={className}
|
||||
role={role}
|
||||
style={style}
|
||||
/>
|
||||
</Fragment>
|
||||
)}
|
||||
/>
|
||||
</tbody>
|
||||
</Table>
|
||||
|
||||
<TableFooter>
|
||||
<PaginationControls
|
||||
showAll
|
||||
pageLimit={pageSize}
|
||||
page={pageIndex + 1}
|
||||
onPageChange={(p) => gotoPage(p - 1)}
|
||||
totalCount={data.length}
|
||||
onPageLimitChange={handlePageSizeChange}
|
||||
/>
|
||||
</TableFooter>
|
||||
</TableContainer>
|
||||
);
|
||||
|
||||
function handlePageSizeChange(pageSize: number) {
|
||||
setPageSize(pageSize);
|
||||
setTableSettings((settings) => ({ ...settings, pageSize }));
|
||||
}
|
||||
|
||||
function handleSearchBarChange(value: string) {
|
||||
setSearchBarValue(value);
|
||||
}
|
||||
|
||||
function handleSortChange(id: string, desc: boolean) {
|
||||
setTableSettings((settings) => ({
|
||||
...settings,
|
||||
sortBy: { id, desc },
|
||||
}));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
import { Column } from 'react-table';
|
||||
|
||||
import { NomadEvent } from '@/react/nomad/types';
|
||||
import { isoDate } from '@/portainer/filters/filters';
|
||||
|
||||
export const date: Column<NomadEvent> = {
|
||||
Header: 'Date',
|
||||
accessor: (row) => (row.Date ? isoDate(row.Date) : '-'),
|
||||
id: 'date',
|
||||
disableFilters: true,
|
||||
canHide: true,
|
||||
};
|
|
@ -0,0 +1,9 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { date } from './date';
|
||||
import { type } from './type';
|
||||
import { message } from './message';
|
||||
|
||||
export function useColumns() {
|
||||
return useMemo(() => [date, type, message], []);
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
import { Column } from 'react-table';
|
||||
|
||||
import { NomadEvent } from '@/react/nomad/types';
|
||||
|
||||
export const message: Column<NomadEvent> = {
|
||||
Header: 'Message',
|
||||
accessor: 'Message',
|
||||
id: 'message',
|
||||
disableFilters: true,
|
||||
canHide: true,
|
||||
};
|
|
@ -0,0 +1,11 @@
|
|||
import { Column } from 'react-table';
|
||||
|
||||
import { NomadEvent } from '@/react/nomad/types';
|
||||
|
||||
export const type: Column<NomadEvent> = {
|
||||
Header: 'Type',
|
||||
accessor: 'Type',
|
||||
id: 'type',
|
||||
disableFilters: true,
|
||||
canHide: true,
|
||||
};
|
1
app/react/nomad/jobs/EventsView/EventsDatatable/index.ts
Normal file
1
app/react/nomad/jobs/EventsView/EventsDatatable/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { EventsDatatable } from './EventsDatatable';
|
62
app/react/nomad/jobs/EventsView/EventsView.tsx
Normal file
62
app/react/nomad/jobs/EventsView/EventsView.tsx
Normal file
|
@ -0,0 +1,62 @@
|
|||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { NomadEventsList } from '@/react/nomad/types';
|
||||
|
||||
import { TableSettingsProvider } from '@@/datatables/useTableSettings';
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
|
||||
import { EventsDatatable } from './EventsDatatable';
|
||||
import { useEvents } from './useEvents';
|
||||
|
||||
export function EventsView() {
|
||||
const environmentId = useEnvironmentId();
|
||||
const { query, invalidateQuery } = useEvents();
|
||||
const {
|
||||
params: { jobID, taskName },
|
||||
} = useCurrentStateAndParams();
|
||||
|
||||
const breadcrumbs = [
|
||||
{
|
||||
label: 'Nomad Jobs',
|
||||
link: 'nomad.jobs',
|
||||
linkParams: { id: environmentId },
|
||||
},
|
||||
{ label: jobID },
|
||||
{ label: taskName },
|
||||
{ label: 'Events' },
|
||||
];
|
||||
|
||||
const defaultSettings = {
|
||||
pageSize: 10,
|
||||
sortBy: {},
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* header */}
|
||||
<PageHeader
|
||||
title="Event list"
|
||||
breadcrumbs={breadcrumbs}
|
||||
reload
|
||||
loading={query.isLoading || query.isFetching}
|
||||
onReload={invalidateQuery}
|
||||
/>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<TableSettingsProvider
|
||||
defaults={defaultSettings}
|
||||
storageKey="nomad-events"
|
||||
>
|
||||
{/* events table */}
|
||||
<EventsDatatable
|
||||
data={(query.data || []) as NomadEventsList}
|
||||
isLoading={query.isLoading}
|
||||
/>
|
||||
</TableSettingsProvider>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
1
app/react/nomad/jobs/EventsView/index.ts
Normal file
1
app/react/nomad/jobs/EventsView/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { EventsView } from './EventsView';
|
75
app/react/nomad/jobs/EventsView/useEvents.ts
Normal file
75
app/react/nomad/jobs/EventsView/useEvents.ts
Normal file
|
@ -0,0 +1,75 @@
|
|||
import { useQuery, useQueryClient } from 'react-query';
|
||||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
|
||||
import * as notifications from '@/portainer/services/notifications';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import { NomadEventsList } from '../../types';
|
||||
|
||||
export function useEvents() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
params: {
|
||||
endpointId: environmentID,
|
||||
allocationID,
|
||||
jobID,
|
||||
taskName,
|
||||
namespace,
|
||||
},
|
||||
} = useCurrentStateAndParams();
|
||||
|
||||
if (!environmentID) {
|
||||
throw new Error('endpointId url param is required');
|
||||
}
|
||||
|
||||
const key = [
|
||||
'environments',
|
||||
environmentID,
|
||||
'nomad',
|
||||
'events',
|
||||
allocationID,
|
||||
jobID,
|
||||
taskName,
|
||||
namespace,
|
||||
];
|
||||
|
||||
function invalidateQuery() {
|
||||
return queryClient.invalidateQueries(key);
|
||||
}
|
||||
|
||||
const query = useQuery(
|
||||
key,
|
||||
() =>
|
||||
getTaskEvents(environmentID, allocationID, jobID, taskName, namespace),
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
onError: (err) => {
|
||||
notifications.error('Failed loading events', err as Error, '');
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return { query, invalidateQuery };
|
||||
}
|
||||
|
||||
export async function getTaskEvents(
|
||||
environmentId: EnvironmentId,
|
||||
allocationId: string,
|
||||
jobId: string,
|
||||
taskName: string,
|
||||
namespace: string
|
||||
) {
|
||||
try {
|
||||
const ret = await axios.get<NomadEventsList>(
|
||||
`/nomad/endpoints/${environmentId}/allocation/${allocationId}/events`,
|
||||
{
|
||||
params: { jobId, taskName, namespace },
|
||||
}
|
||||
);
|
||||
return ret.data;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error);
|
||||
}
|
||||
}
|
204
app/react/nomad/jobs/JobsView/JobsDatatable/JobsDatatable.tsx
Normal file
204
app/react/nomad/jobs/JobsView/JobsDatatable/JobsDatatable.tsx
Normal file
|
@ -0,0 +1,204 @@
|
|||
import { Fragment, useEffect } from 'react';
|
||||
import {
|
||||
useExpanded,
|
||||
useFilters,
|
||||
useGlobalFilter,
|
||||
usePagination,
|
||||
useSortBy,
|
||||
useTable,
|
||||
} from 'react-table';
|
||||
import { useRowSelectColumn } from '@lineup-lite/hooks';
|
||||
|
||||
import { Job } from '@/react/nomad/types';
|
||||
import { useDebounce } from '@/react/hooks/useDebounce';
|
||||
|
||||
import { PaginationControls } from '@@/PaginationControls';
|
||||
import {
|
||||
Table,
|
||||
TableActions,
|
||||
TableContainer,
|
||||
TableHeaderRow,
|
||||
TableRow,
|
||||
TableTitle,
|
||||
TableSettingsMenu,
|
||||
TableTitleActions,
|
||||
} from '@@/datatables';
|
||||
import { multiple } from '@@/datatables/filter-types';
|
||||
import { useTableSettings } from '@@/datatables/useTableSettings';
|
||||
import { SearchBar, useSearchBarState } from '@@/datatables/SearchBar';
|
||||
import { useRowSelect } from '@@/datatables/useRowSelect';
|
||||
import { TableFooter } from '@@/datatables/TableFooter';
|
||||
import { SelectedRowsCount } from '@@/datatables/SelectedRowsCount';
|
||||
import { TableContent } from '@@/datatables/TableContent';
|
||||
import { useRepeater } from '@@/datatables/useRepeater';
|
||||
|
||||
import { JobsTableSettings } from './types';
|
||||
import { TasksDatatable } from './TasksDatatable';
|
||||
import { useColumns } from './columns';
|
||||
import { JobsDatatableSettings } from './JobsDatatableSettings';
|
||||
|
||||
export interface JobsDatatableProps {
|
||||
jobs: Job[];
|
||||
refreshData: () => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function JobsDatatable({
|
||||
jobs,
|
||||
refreshData,
|
||||
isLoading,
|
||||
}: JobsDatatableProps) {
|
||||
const { settings, setTableSettings } = useTableSettings<JobsTableSettings>();
|
||||
const [searchBarValue, setSearchBarValue] = useSearchBarState('jobs');
|
||||
const columns = useColumns();
|
||||
const debouncedSearchValue = useDebounce(searchBarValue);
|
||||
useRepeater(settings.autoRefreshRate, refreshData);
|
||||
|
||||
const {
|
||||
getTableProps,
|
||||
getTableBodyProps,
|
||||
headerGroups,
|
||||
page,
|
||||
prepareRow,
|
||||
selectedFlatRows,
|
||||
gotoPage,
|
||||
setPageSize,
|
||||
setGlobalFilter,
|
||||
state: { pageIndex, pageSize },
|
||||
} = useTable<Job>(
|
||||
{
|
||||
defaultCanFilter: false,
|
||||
columns,
|
||||
data: jobs,
|
||||
filterTypes: { multiple },
|
||||
initialState: {
|
||||
pageSize: settings.pageSize || 10,
|
||||
sortBy: [settings.sortBy],
|
||||
globalFilter: searchBarValue,
|
||||
},
|
||||
isRowSelectable() {
|
||||
return false;
|
||||
},
|
||||
autoResetExpanded: false,
|
||||
autoResetSelectedRows: false,
|
||||
selectColumnWidth: 5,
|
||||
getRowId(job, relativeIndex) {
|
||||
return `${job.ID}-${relativeIndex}`;
|
||||
},
|
||||
},
|
||||
useFilters,
|
||||
useGlobalFilter,
|
||||
useSortBy,
|
||||
useExpanded,
|
||||
usePagination,
|
||||
useRowSelect,
|
||||
useRowSelectColumn
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setGlobalFilter(debouncedSearchValue);
|
||||
}, [debouncedSearchValue, setGlobalFilter]);
|
||||
|
||||
const tableProps = getTableProps();
|
||||
const tbodyProps = getTableBodyProps();
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<TableTitle icon="fa-cubes" label="Nomad Jobs">
|
||||
<TableTitleActions>
|
||||
<TableSettingsMenu>
|
||||
<JobsDatatableSettings />
|
||||
</TableSettingsMenu>
|
||||
</TableTitleActions>
|
||||
</TableTitle>
|
||||
|
||||
<TableActions />
|
||||
|
||||
<SearchBar value={searchBarValue} onChange={handleSearchBarChange} />
|
||||
|
||||
<Table
|
||||
className={tableProps.className}
|
||||
role={tableProps.role}
|
||||
style={tableProps.style}
|
||||
>
|
||||
<thead>
|
||||
{headerGroups.map((headerGroup) => {
|
||||
const { key, className, role, style } =
|
||||
headerGroup.getHeaderGroupProps();
|
||||
|
||||
return (
|
||||
<TableHeaderRow<Job>
|
||||
key={key}
|
||||
className={className}
|
||||
role={role}
|
||||
style={style}
|
||||
headers={headerGroup.headers}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</thead>
|
||||
<tbody
|
||||
className={tbodyProps.className}
|
||||
role={tbodyProps.role}
|
||||
style={tbodyProps.style}
|
||||
>
|
||||
<TableContent
|
||||
rows={page}
|
||||
prepareRow={prepareRow}
|
||||
isLoading={isLoading}
|
||||
emptyContent="No jobs found"
|
||||
renderRow={(row, { key, className, role, style }) => (
|
||||
<Fragment key={key}>
|
||||
<TableRow<Job>
|
||||
cells={row.cells}
|
||||
key={key}
|
||||
className={className}
|
||||
role={role}
|
||||
style={style}
|
||||
/>
|
||||
|
||||
{row.isExpanded && (
|
||||
<tr>
|
||||
<td />
|
||||
<td colSpan={row.cells.length - 1}>
|
||||
<TasksDatatable data={row.original.Tasks} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
)}
|
||||
/>
|
||||
</tbody>
|
||||
</Table>
|
||||
|
||||
<TableFooter>
|
||||
<SelectedRowsCount value={selectedFlatRows.length} />
|
||||
<PaginationControls
|
||||
showAll
|
||||
pageLimit={pageSize}
|
||||
page={pageIndex + 1}
|
||||
onPageChange={(p) => gotoPage(p - 1)}
|
||||
totalCount={jobs.length}
|
||||
onPageLimitChange={handlePageSizeChange}
|
||||
/>
|
||||
</TableFooter>
|
||||
</TableContainer>
|
||||
);
|
||||
|
||||
function handlePageSizeChange(pageSize: number) {
|
||||
setPageSize(pageSize);
|
||||
setTableSettings((settings) => ({ ...settings, pageSize }));
|
||||
}
|
||||
|
||||
function handleSearchBarChange(value: string) {
|
||||
setSearchBarValue(value);
|
||||
}
|
||||
|
||||
function handleSortChange(id: string, desc: boolean) {
|
||||
setTableSettings((settings) => ({
|
||||
...settings,
|
||||
sortBy: { id, desc },
|
||||
}));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
|
||||
import { useTableSettings } from '@@/datatables/useTableSettings';
|
||||
|
||||
import { JobsTableSettings } from './types';
|
||||
|
||||
export function JobsDatatableSettings() {
|
||||
const { settings, setTableSettings } = useTableSettings<JobsTableSettings>();
|
||||
|
||||
return (
|
||||
<TableSettingsMenuAutoRefresh
|
||||
value={settings.autoRefreshRate}
|
||||
onChange={handleRefreshRateChange}
|
||||
/>
|
||||
);
|
||||
|
||||
function handleRefreshRateChange(autoRefreshRate: number) {
|
||||
setTableSettings({ autoRefreshRate });
|
||||
}
|
||||
}
|
|
@ -0,0 +1,97 @@
|
|||
import { useFilters, usePagination, useSortBy, useTable } from 'react-table';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Task } from '@/react/nomad/types';
|
||||
|
||||
import { Table, TableContainer, TableHeaderRow, TableRow } from '@@/datatables';
|
||||
import { InnerDatatable } from '@@/datatables/InnerDatatable';
|
||||
|
||||
import { useColumns } from './columns';
|
||||
|
||||
export interface TasksTableProps {
|
||||
data: Task[];
|
||||
}
|
||||
|
||||
export function TasksDatatable({ data }: TasksTableProps) {
|
||||
const columns = useColumns();
|
||||
const [sortBy, setSortBy] = useState({ id: 'taskName', desc: false });
|
||||
|
||||
const { getTableProps, getTableBodyProps, headerGroups, page, prepareRow } =
|
||||
useTable<Task>(
|
||||
{
|
||||
columns,
|
||||
data,
|
||||
initialState: {
|
||||
sortBy: [sortBy],
|
||||
},
|
||||
},
|
||||
useFilters,
|
||||
useSortBy,
|
||||
usePagination
|
||||
);
|
||||
|
||||
const tableProps = getTableProps();
|
||||
const tbodyProps = getTableBodyProps();
|
||||
|
||||
return (
|
||||
<InnerDatatable>
|
||||
<TableContainer>
|
||||
<Table
|
||||
className={tableProps.className}
|
||||
role={tableProps.role}
|
||||
style={tableProps.style}
|
||||
>
|
||||
<thead>
|
||||
{headerGroups.map((headerGroup) => {
|
||||
const { key, className, role, style } =
|
||||
headerGroup.getHeaderGroupProps();
|
||||
|
||||
return (
|
||||
<TableHeaderRow<Task>
|
||||
key={key}
|
||||
className={className}
|
||||
role={role}
|
||||
style={style}
|
||||
headers={headerGroup.headers}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</thead>
|
||||
<tbody
|
||||
className={tbodyProps.className}
|
||||
role={tbodyProps.role}
|
||||
style={tbodyProps.style}
|
||||
>
|
||||
{data.length > 0 ? (
|
||||
page.map((row) => {
|
||||
prepareRow(row);
|
||||
const { key, className, role, style } = row.getRowProps();
|
||||
|
||||
return (
|
||||
<TableRow<Task>
|
||||
key={key}
|
||||
cells={row.cells}
|
||||
className={className}
|
||||
role={role}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center text-muted">
|
||||
no tasks
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</InnerDatatable>
|
||||
);
|
||||
|
||||
function handleSortChange(id: string, desc: boolean) {
|
||||
setSortBy({ id, desc });
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
import { CellProps, Column } from 'react-table';
|
||||
|
||||
import { Task } from '@/react/nomad/types';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
export const actions: Column<Task> = {
|
||||
Header: 'Task Actions',
|
||||
id: 'actions',
|
||||
disableFilters: true,
|
||||
canHide: true,
|
||||
disableResizing: true,
|
||||
width: '5px',
|
||||
sortType: 'string',
|
||||
Filter: () => null,
|
||||
Cell: ActionsCell,
|
||||
};
|
||||
|
||||
export function ActionsCell({ row }: CellProps<Task>) {
|
||||
const params = {
|
||||
allocationID: row.original.AllocationID,
|
||||
taskName: row.original.TaskName,
|
||||
namespace: row.original.Namespace,
|
||||
jobID: row.original.JobID,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="text-center">
|
||||
{/* events */}
|
||||
<Link
|
||||
to="nomad.events"
|
||||
params={params}
|
||||
title="Events"
|
||||
className="space-right"
|
||||
>
|
||||
<i className="fa fa-history space-right" aria-hidden="true" />
|
||||
</Link>
|
||||
|
||||
{/* logs */}
|
||||
<Link to="nomad.logs" params={params} title="Logs">
|
||||
<i className="fa fa-file-alt space-right" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
import { Column } from 'react-table';
|
||||
|
||||
import { Task } from '@/react/nomad/types';
|
||||
|
||||
export const allocationID: Column<Task> = {
|
||||
Header: 'Allocation ID',
|
||||
accessor: (row) => row.AllocationID || '-',
|
||||
id: 'allocationID',
|
||||
disableFilters: true,
|
||||
canHide: true,
|
||||
};
|
|
@ -0,0 +1,15 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { taskStatus } from './taskStatus';
|
||||
import { taskName } from './taskName';
|
||||
import { taskGroup } from './taskGroup';
|
||||
import { allocationID } from './allocationID';
|
||||
import { started } from './started';
|
||||
import { actions } from './actions';
|
||||
|
||||
export function useColumns() {
|
||||
return useMemo(
|
||||
() => [taskStatus, taskName, taskGroup, allocationID, actions, started],
|
||||
[]
|
||||
);
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
import moment from 'moment';
|
||||
import { Column } from 'react-table';
|
||||
|
||||
import { Task } from '@/react/nomad/types';
|
||||
import { isoDate } from '@/portainer/filters/filters';
|
||||
|
||||
function accessor(row: Task) {
|
||||
const momentDate = moment(row.StartedAt);
|
||||
const isValid = momentDate.unix() > 0;
|
||||
return isValid ? isoDate(momentDate) : '-';
|
||||
}
|
||||
|
||||
export const started: Column<Task> = {
|
||||
accessor,
|
||||
Header: 'Started',
|
||||
id: 'startedName',
|
||||
disableFilters: true,
|
||||
canHide: true,
|
||||
};
|
|
@ -0,0 +1,11 @@
|
|||
import { Column } from 'react-table';
|
||||
|
||||
import { Task } from '@/react/nomad/types';
|
||||
|
||||
export const taskGroup: Column<Task> = {
|
||||
Header: 'Task Group',
|
||||
accessor: (row) => row.TaskGroup || '-',
|
||||
id: 'taskGroup',
|
||||
disableFilters: true,
|
||||
canHide: true,
|
||||
};
|
|
@ -0,0 +1,11 @@
|
|||
import { Column } from 'react-table';
|
||||
|
||||
import { Task } from '@/react/nomad/types';
|
||||
|
||||
export const taskName: Column<Task> = {
|
||||
Header: 'Task Name',
|
||||
accessor: (row) => row.TaskName || '-',
|
||||
id: 'taskName',
|
||||
disableFilters: true,
|
||||
canHide: true,
|
||||
};
|
|
@ -0,0 +1,35 @@
|
|||
import _ from 'lodash';
|
||||
import clsx from 'clsx';
|
||||
import { CellProps, Column } from 'react-table';
|
||||
|
||||
import { Task } from '@/react/nomad/types';
|
||||
|
||||
import { DefaultFilter } from '@@/datatables/Filter';
|
||||
|
||||
export const taskStatus: Column<Task> = {
|
||||
Header: 'Task Status',
|
||||
accessor: 'State',
|
||||
id: 'status',
|
||||
Filter: DefaultFilter,
|
||||
canHide: true,
|
||||
sortType: 'string',
|
||||
Cell: StateCell,
|
||||
};
|
||||
|
||||
function StateCell({ value }: CellProps<Task, string>) {
|
||||
const className = getClassName();
|
||||
|
||||
return <span className={clsx('label', className)}>{value}</span>;
|
||||
|
||||
function getClassName() {
|
||||
if (['dead'].includes(_.toLower(value))) {
|
||||
return 'label-danger';
|
||||
}
|
||||
|
||||
if (['pending'].includes(_.toLower(value))) {
|
||||
return 'label-warning';
|
||||
}
|
||||
|
||||
return 'label-success';
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { TasksDatatable } from './TasksDatatable';
|
|
@ -0,0 +1,49 @@
|
|||
import { useMutation } from 'react-query';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { Job } from '@/react/nomad/types';
|
||||
import { confirmDeletionAsync } from '@/portainer/services/modal.service/confirm';
|
||||
|
||||
import { LoadingButton } from '@@/buttons/LoadingButton';
|
||||
|
||||
import { deleteJobs } from './delete';
|
||||
|
||||
interface Props {
|
||||
selectedItems: Job[];
|
||||
refreshData: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export function JobActions({ selectedItems, refreshData }: Props) {
|
||||
const environmentId = useEnvironmentId();
|
||||
|
||||
const mutation = useMutation(() => deleteJobs(environmentId, selectedItems));
|
||||
|
||||
return (
|
||||
<LoadingButton
|
||||
loadingText="Removing..."
|
||||
isLoading={mutation.isLoading}
|
||||
disabled={selectedItems.length < 1 || mutation.isLoading}
|
||||
color="danger"
|
||||
onClick={handleDeleteClicked}
|
||||
>
|
||||
<i className="fa fa-trash-alt space-right" aria-hidden="true" />
|
||||
Remove
|
||||
</LoadingButton>
|
||||
);
|
||||
|
||||
async function handleDeleteClicked() {
|
||||
const confirmed = await confirmDeletionAsync(
|
||||
'Are you sure to delete all selected jobs?'
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
mutation.mutate(undefined, {
|
||||
onSuccess() {
|
||||
return refreshData();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
import * as notifications from '@/portainer/services/notifications';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { Job } from '@/react/nomad/types';
|
||||
|
||||
import { deleteJob } from '../../../jobs.service';
|
||||
|
||||
export async function deleteJobs(environmentID: EnvironmentId, jobs: Job[]) {
|
||||
return Promise.all(
|
||||
jobs.map(async (job) => {
|
||||
try {
|
||||
await deleteJob(environmentID, job.ID, job.Namespace);
|
||||
notifications.success('Job successfully removed', job.ID);
|
||||
} catch (err) {
|
||||
notifications.error(
|
||||
'Failure',
|
||||
err as Error,
|
||||
`Failed to delete job ${job.ID}`
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
import { CellProps, Column } from 'react-table';
|
||||
|
||||
import { Job } from '@/react/nomad/types';
|
||||
|
||||
export const actions: Column<Job> = {
|
||||
Header: 'Job Actions',
|
||||
id: 'actions',
|
||||
disableFilters: true,
|
||||
canHide: true,
|
||||
disableResizing: true,
|
||||
width: '110px',
|
||||
sortType: 'string',
|
||||
Filter: () => null,
|
||||
Cell: ActionsCell,
|
||||
};
|
||||
|
||||
export function ActionsCell({ row }: CellProps<Job>) {
|
||||
return (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<div className="text-center" {...row.getToggleRowExpandedProps()}>
|
||||
<i className="fa fa-history space-right" aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import { Column } from 'react-table';
|
||||
|
||||
import { Job } from '@/react/nomad/types';
|
||||
import { isoDate } from '@/portainer/filters/filters';
|
||||
|
||||
export const created: Column<Job> = {
|
||||
Header: 'Created',
|
||||
accessor: (row) =>
|
||||
row.SubmitTime ? isoDate(parseInt(row.SubmitTime, 10)) : '-',
|
||||
id: 'createdName',
|
||||
disableFilters: true,
|
||||
canHide: true,
|
||||
};
|
|
@ -0,0 +1,11 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { name } from './name';
|
||||
import { status } from './status';
|
||||
import { created } from './created';
|
||||
import { actions } from './actions';
|
||||
import { namespace } from './namespace';
|
||||
|
||||
export function useColumns() {
|
||||
return useMemo(() => [name, status, namespace, actions, created], []);
|
||||
}
|
24
app/react/nomad/jobs/JobsView/JobsDatatable/columns/name.tsx
Normal file
24
app/react/nomad/jobs/JobsView/JobsDatatable/columns/name.tsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { CellProps, Column } from 'react-table';
|
||||
|
||||
import { Job } from '@/react/nomad/types';
|
||||
|
||||
import { ExpandingCell } from '@@/datatables/ExpandingCell';
|
||||
|
||||
export const name: Column<Job> = {
|
||||
Header: 'Name',
|
||||
accessor: (row) => row.ID,
|
||||
id: 'name',
|
||||
Cell: NameCell,
|
||||
disableFilters: true,
|
||||
Filter: () => null,
|
||||
canHide: false,
|
||||
sortType: 'string',
|
||||
};
|
||||
|
||||
export function NameCell({ value: name, row }: CellProps<Job>) {
|
||||
return (
|
||||
<ExpandingCell row={row} showExpandArrow>
|
||||
{name}
|
||||
</ExpandingCell>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
import { Column } from 'react-table';
|
||||
|
||||
import { Job } from '@/react/nomad/types';
|
||||
|
||||
export const namespace: Column<Job> = {
|
||||
Header: 'Namespace',
|
||||
accessor: (row) => row.Namespace || '-',
|
||||
id: 'namespace',
|
||||
disableFilters: true,
|
||||
canHide: true,
|
||||
};
|
|
@ -0,0 +1,11 @@
|
|||
import { Column } from 'react-table';
|
||||
|
||||
import { Job } from '@/react/nomad/types';
|
||||
|
||||
export const status: Column<Job> = {
|
||||
Header: 'Job Status',
|
||||
accessor: (row) => row.Status || '-',
|
||||
id: 'statusName',
|
||||
disableFilters: true,
|
||||
canHide: true,
|
||||
};
|
1
app/react/nomad/jobs/JobsView/JobsDatatable/index.ts
Normal file
1
app/react/nomad/jobs/JobsView/JobsDatatable/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { JobsDatatable } from './JobsDatatable';
|
5
app/react/nomad/jobs/JobsView/JobsDatatable/types.ts
Normal file
5
app/react/nomad/jobs/JobsView/JobsDatatable/types.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export interface JobsTableSettings {
|
||||
autoRefreshRate: number;
|
||||
pageSize: number;
|
||||
sortBy: { id: string; desc: boolean };
|
||||
}
|
46
app/react/nomad/jobs/JobsView/JobsView.tsx
Normal file
46
app/react/nomad/jobs/JobsView/JobsView.tsx
Normal file
|
@ -0,0 +1,46 @@
|
|||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
import { TableSettingsProvider } from '@@/datatables/useTableSettings';
|
||||
|
||||
import { useJobs } from './useJobs';
|
||||
import { JobsDatatable } from './JobsDatatable';
|
||||
|
||||
export function JobsView() {
|
||||
const environmentId = useEnvironmentId();
|
||||
const jobsQuery = useJobs(environmentId);
|
||||
|
||||
const defaultSettings = {
|
||||
autoRefreshRate: 10,
|
||||
pageSize: 10,
|
||||
sortBy: { id: 'name', desc: false },
|
||||
};
|
||||
|
||||
async function reloadData() {
|
||||
await jobsQuery.refetch();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Nomad Job list"
|
||||
breadcrumbs={[{ label: 'Nomad Jobs' }]}
|
||||
reload
|
||||
loading={jobsQuery.isLoading}
|
||||
onReload={reloadData}
|
||||
/>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<TableSettingsProvider defaults={defaultSettings} storageKey="jobs">
|
||||
<JobsDatatable
|
||||
jobs={jobsQuery.data || []}
|
||||
refreshData={reloadData}
|
||||
isLoading={jobsQuery.isLoading}
|
||||
/>
|
||||
</TableSettingsProvider>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
1
app/react/nomad/jobs/JobsView/index.ts
Normal file
1
app/react/nomad/jobs/JobsView/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { JobsView } from './JobsView';
|
34
app/react/nomad/jobs/JobsView/useJobs.ts
Normal file
34
app/react/nomad/jobs/JobsView/useJobs.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { Job } from '@/react/nomad/types';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
export function useJobs(environmentId: EnvironmentId) {
|
||||
return useQuery<Job[]>(
|
||||
['environments', environmentId, 'nomad', 'jobs'],
|
||||
() => listJobs(environmentId),
|
||||
{
|
||||
meta: {
|
||||
error: {
|
||||
title: 'Failure',
|
||||
message: 'Unable to list jobs',
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function listJobs(environmentId: EnvironmentId) {
|
||||
try {
|
||||
const { data: jobs } = await axios.get<Job[]>(
|
||||
`/nomad/endpoints/${environmentId}/jobs`,
|
||||
{
|
||||
params: {},
|
||||
}
|
||||
);
|
||||
return jobs;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error);
|
||||
}
|
||||
}
|
16
app/react/nomad/jobs/jobs.service.ts
Normal file
16
app/react/nomad/jobs/jobs.service.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
export async function deleteJob(
|
||||
environmentId: EnvironmentId,
|
||||
jobId: string,
|
||||
namespace: string
|
||||
) {
|
||||
try {
|
||||
await axios.delete(`/nomad/endpoints/${environmentId}/jobs/${jobId}`, {
|
||||
params: { namespace },
|
||||
});
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error);
|
||||
}
|
||||
}
|
20
app/react/nomad/nomad.service.ts
Normal file
20
app/react/nomad/nomad.service.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
interface LeaderResponse {
|
||||
Leader: string;
|
||||
}
|
||||
|
||||
export async function getLeader(environmentId: EnvironmentId) {
|
||||
try {
|
||||
const { data } = await axios.get<LeaderResponse>(
|
||||
`/nomad/endpoints/${environmentId}/leader`,
|
||||
{
|
||||
params: {},
|
||||
}
|
||||
);
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error);
|
||||
}
|
||||
}
|
25
app/react/nomad/types.ts
Normal file
25
app/react/nomad/types.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
export type NomadEvent = {
|
||||
Type: string;
|
||||
Message: string;
|
||||
Date: number;
|
||||
};
|
||||
|
||||
export type NomadEventsList = NomadEvent[];
|
||||
|
||||
export type Task = {
|
||||
JobID: string;
|
||||
Namespace: string;
|
||||
TaskName: string;
|
||||
State: string;
|
||||
TaskGroup: string;
|
||||
AllocationID: string;
|
||||
StartedAt: string;
|
||||
};
|
||||
|
||||
export type Job = {
|
||||
ID: string;
|
||||
Status: string;
|
||||
Namespace: string;
|
||||
SubmitTime: string;
|
||||
Tasks: Task[];
|
||||
};
|
|
@ -22,6 +22,7 @@ import { useTags } from '@/portainer/tags/queries';
|
|||
import { useAgentVersionsList } from '@/react/portainer/environments/queries/useAgentVersionsList';
|
||||
import { EnvironmentsQueryParams } from '@/react/portainer/environments/environment.service';
|
||||
import { useUser } from '@/react/hooks/useUser';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
import { TableFooter } from '@@/datatables/TableFooter';
|
||||
import { TableActions, TableContainer, TableTitle } from '@@/datatables';
|
||||
|
@ -350,6 +351,7 @@ export function EnvironmentList({ onClickItem, onRefresh }: Props) {
|
|||
EnvironmentType.AgentOnKubernetes,
|
||||
EnvironmentType.EdgeAgentOnKubernetes,
|
||||
],
|
||||
[PlatformType.Nomad]: [EnvironmentType.EdgeAgentOnNomad],
|
||||
};
|
||||
|
||||
const typesByConnection = {
|
||||
|
@ -475,6 +477,7 @@ function getConnectionTypeOptions(platformTypes: Filter<PlatformType>[]) {
|
|||
ConnectionType.EdgeAgent,
|
||||
ConnectionType.EdgeDevice,
|
||||
],
|
||||
[PlatformType.Nomad]: [ConnectionType.EdgeAgent, ConnectionType.EdgeDevice],
|
||||
};
|
||||
|
||||
const connectionTypesDefaultOptions = [
|
||||
|
@ -501,6 +504,13 @@ function getPlatformTypeOptions(connectionTypes: Filter<ConnectionType>[]) {
|
|||
{ value: PlatformType.Kubernetes, label: 'Kubernetes' },
|
||||
];
|
||||
|
||||
if (isBE) {
|
||||
platformDefaultOptions.push({
|
||||
value: PlatformType.Nomad,
|
||||
label: 'Nomad',
|
||||
});
|
||||
}
|
||||
|
||||
if (connectionTypes.length === 0) {
|
||||
return platformDefaultOptions;
|
||||
}
|
||||
|
@ -508,8 +518,16 @@ function getPlatformTypeOptions(connectionTypes: Filter<ConnectionType>[]) {
|
|||
const connectionTypePlatformType = {
|
||||
[ConnectionType.API]: [PlatformType.Docker, PlatformType.Azure],
|
||||
[ConnectionType.Agent]: [PlatformType.Docker, PlatformType.Kubernetes],
|
||||
[ConnectionType.EdgeAgent]: [PlatformType.Kubernetes, PlatformType.Docker],
|
||||
[ConnectionType.EdgeDevice]: [PlatformType.Docker, PlatformType.Kubernetes],
|
||||
[ConnectionType.EdgeAgent]: [
|
||||
PlatformType.Kubernetes,
|
||||
PlatformType.Nomad,
|
||||
PlatformType.Docker,
|
||||
],
|
||||
[ConnectionType.EdgeDevice]: [
|
||||
PlatformType.Nomad,
|
||||
PlatformType.Docker,
|
||||
PlatformType.Kubernetes,
|
||||
],
|
||||
};
|
||||
|
||||
return _.compact(
|
||||
|
|
|
@ -18,6 +18,8 @@ export enum EnvironmentType {
|
|||
AgentOnKubernetes,
|
||||
// EdgeAgentOnKubernetes represents an environment(endpoint) connected to an Edge agent deployed on a Kubernetes environment(endpoint)
|
||||
EdgeAgentOnKubernetes,
|
||||
// EdgeAgentOnNomad represents an environment(endpoint) connected to an Edge agent deployed on a Nomad environment(endpoint)
|
||||
EdgeAgentOnNomad,
|
||||
}
|
||||
|
||||
export const EdgeTypes = [
|
||||
|
@ -147,4 +149,5 @@ export enum PlatformType {
|
|||
Docker,
|
||||
Kubernetes,
|
||||
Azure,
|
||||
Nomad,
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import {
|
|||
import Docker from './docker.svg?c';
|
||||
import Azure from './azure.svg?c';
|
||||
import Kubernetes from './kubernetes.svg?c';
|
||||
import Nomad from './nomad.svg?c';
|
||||
|
||||
const icons: {
|
||||
[key in PlatformType]: SvgrComponent;
|
||||
|
@ -14,6 +15,7 @@ const icons: {
|
|||
[PlatformType.Docker]: Docker,
|
||||
[PlatformType.Kubernetes]: Kubernetes,
|
||||
[PlatformType.Azure]: Azure,
|
||||
[PlatformType.Nomad]: Nomad,
|
||||
};
|
||||
|
||||
export function getPlatformIcon(type: EnvironmentType) {
|
||||
|
|
|
@ -12,8 +12,10 @@ export function getPlatformType(envType: EnvironmentType) {
|
|||
return PlatformType.Docker;
|
||||
case EnvironmentType.Azure:
|
||||
return PlatformType.Azure;
|
||||
case EnvironmentType.EdgeAgentOnNomad:
|
||||
return PlatformType.Nomad;
|
||||
default:
|
||||
throw new Error(`${envType} is not a supported environment type`);
|
||||
throw new Error(`Environment Type ${envType} is not supported`);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -25,6 +27,14 @@ export function isKubernetesEnvironment(envType: EnvironmentType) {
|
|||
return getPlatformType(envType) === PlatformType.Kubernetes;
|
||||
}
|
||||
|
||||
export function getPlatformTypeName(envType: EnvironmentType): string {
|
||||
return PlatformType[getPlatformType(envType)];
|
||||
}
|
||||
|
||||
export function isNomadEnvironment(envType: EnvironmentType) {
|
||||
return getPlatformType(envType) === PlatformType.Nomad;
|
||||
}
|
||||
|
||||
export function isAgentEnvironment(envType: EnvironmentType) {
|
||||
return (
|
||||
isEdgeEnvironment(envType) ||
|
||||
|
|
|
@ -13,6 +13,7 @@ import { getPlatformType } from '@/react/portainer/environments/utils';
|
|||
import { useEnvironment } from '@/react/portainer/environments/queries/useEnvironment';
|
||||
import { useLocalStorage } from '@/react/hooks/useLocalStorage';
|
||||
import { EndpointProviderInterface } from '@/portainer/services/endpointProvider';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
|
||||
import { getPlatformIcon } from '../portainer/environments/utils/get-platform-icon';
|
||||
|
||||
|
@ -22,6 +23,7 @@ import { DockerSidebar } from './DockerSidebar';
|
|||
import { KubernetesSidebar } from './KubernetesSidebar';
|
||||
import { SidebarSection, SidebarSectionTitle } from './SidebarSection';
|
||||
import { useSidebarState } from './useSidebarState';
|
||||
import { NomadSidebar } from './NomadSidebar';
|
||||
|
||||
export function EnvironmentSidebar() {
|
||||
const { query: currentEnvironmentQuery, clearEnvironment } =
|
||||
|
@ -67,7 +69,9 @@ function Content({ environment, onClear }: ContentProps) {
|
|||
showTitleWhenOpen
|
||||
>
|
||||
<div className="mt-2">
|
||||
<Sidebar environmentId={environment.Id} environment={environment} />
|
||||
{Sidebar && (
|
||||
<Sidebar environmentId={environment.Id} environment={environment} />
|
||||
)}
|
||||
</div>
|
||||
</SidebarSection>
|
||||
);
|
||||
|
@ -77,11 +81,12 @@ function Content({ environment, onClear }: ContentProps) {
|
|||
[key in PlatformType]: React.ComponentType<{
|
||||
environmentId: EnvironmentId;
|
||||
environment: Environment;
|
||||
}>;
|
||||
}> | null;
|
||||
} = {
|
||||
[PlatformType.Azure]: AzureSidebar,
|
||||
[PlatformType.Docker]: DockerSidebar,
|
||||
[PlatformType.Kubernetes]: KubernetesSidebar,
|
||||
[PlatformType.Nomad]: isBE ? NomadSidebar : null,
|
||||
};
|
||||
|
||||
return sidebar[platform];
|
||||
|
|
38
app/react/sidebar/NomadSidebar/NomadSidebar.test.tsx
Normal file
38
app/react/sidebar/NomadSidebar/NomadSidebar.test.tsx
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { UserContext } from '@/react/hooks/useUser';
|
||||
import { UserViewModel } from '@/portainer/models/user';
|
||||
import { render, within } from '@/react-tools/test-utils';
|
||||
|
||||
import { TestSidebarProvider } from '../useSidebarState';
|
||||
|
||||
import { NomadSidebar } from './NomadSidebar';
|
||||
|
||||
test('dashboard items should render correctly', () => {
|
||||
const { getByLabelText } = renderComponent();
|
||||
const dashboardItem = getByLabelText(/Dashboard/i);
|
||||
expect(dashboardItem).toBeVisible();
|
||||
expect(dashboardItem).toHaveTextContent('Dashboard');
|
||||
|
||||
const dashboardItemElements = within(dashboardItem);
|
||||
expect(
|
||||
dashboardItemElements.getByRole('img', { hidden: true })
|
||||
).toBeVisible();
|
||||
|
||||
const jobsItem = getByLabelText('Nomad Jobs');
|
||||
expect(jobsItem).toBeVisible();
|
||||
expect(jobsItem).toHaveTextContent('Jobs');
|
||||
|
||||
const jobsItemElements = within(jobsItem);
|
||||
expect(jobsItemElements.getByRole('img', { hidden: true })).toBeVisible();
|
||||
});
|
||||
|
||||
function renderComponent() {
|
||||
const user = new UserViewModel({ Username: 'user' });
|
||||
|
||||
return render(
|
||||
<UserContext.Provider value={{ user }}>
|
||||
<TestSidebarProvider>
|
||||
<NomadSidebar environmentId={1} />
|
||||
</TestSidebarProvider>
|
||||
</UserContext.Provider>
|
||||
);
|
||||
}
|
30
app/react/sidebar/NomadSidebar/NomadSidebar.tsx
Normal file
30
app/react/sidebar/NomadSidebar/NomadSidebar.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { Clock } from 'react-feather';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { DashboardLink } from '../items/DashboardLink';
|
||||
import { SidebarItem } from '../SidebarItem';
|
||||
|
||||
interface Props {
|
||||
environmentId: EnvironmentId;
|
||||
}
|
||||
|
||||
export function NomadSidebar({ environmentId }: Props) {
|
||||
return (
|
||||
<>
|
||||
<DashboardLink
|
||||
environmentId={environmentId}
|
||||
platformPath="nomad"
|
||||
data-cy="nomadSidebar-dashboard"
|
||||
/>
|
||||
|
||||
<SidebarItem
|
||||
to="nomad.jobs"
|
||||
params={{ endpointId: environmentId }}
|
||||
icon={Clock}
|
||||
label="Nomad Jobs"
|
||||
data-cy="nomadSidebar-jobs"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
1
app/react/sidebar/NomadSidebar/index.ts
Normal file
1
app/react/sidebar/NomadSidebar/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { NomadSidebar } from './NomadSidebar';
|
Loading…
Add table
Add a link
Reference in a new issue