[release] version 0.5.0-unstable
This commit is contained in:
parent
b76f0650d8
commit
8b4d738c2e
|
@ -157,6 +157,22 @@ function createVolume(values) {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function attachTerminal(containerId) {
|
||||||
|
let protocol = 'ws://';
|
||||||
|
if (window.location.protocol === 'https:') {
|
||||||
|
protocol = 'wss://';
|
||||||
|
}
|
||||||
|
return new WebSocket(protocol + window.location.host + '/cosmos/api/servapps/' + containerId + '/terminal/attach');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTerminal(containerId) {
|
||||||
|
let protocol = 'ws://';
|
||||||
|
if (window.location.protocol === 'https:') {
|
||||||
|
protocol = 'wss://';
|
||||||
|
}
|
||||||
|
return new WebSocket(protocol + window.location.host + '/cosmos/api/servapps/' + containerId + '/terminal/new');
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
list,
|
list,
|
||||||
get,
|
get,
|
||||||
|
@ -174,4 +190,6 @@ export {
|
||||||
attachNetwork,
|
attachNetwork,
|
||||||
detachNetwork,
|
detachNetwork,
|
||||||
createVolume,
|
createVolume,
|
||||||
|
attachTerminal,
|
||||||
|
createTerminal,
|
||||||
};
|
};
|
|
@ -56,7 +56,6 @@ const LogLine = ({ message, docker, isMobile }) => {
|
||||||
if(docker) {
|
if(docker) {
|
||||||
let parts = html.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z/)
|
let parts = html.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z/)
|
||||||
if(!parts) {
|
if(!parts) {
|
||||||
console.error('Could not parse log line', html)
|
|
||||||
return <div dangerouslySetInnerHTML={{ __html: html }} />;
|
return <div dangerouslySetInnerHTML={{ __html: html }} />;
|
||||||
}
|
}
|
||||||
let restString = html.replace(parts[0], '')
|
let restString = html.replace(parts[0], '')
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Button, useMediaQuery, IconButton } from "@mui/material";
|
import { Button, useMediaQuery, IconButton } from "@mui/material";
|
||||||
|
|
||||||
|
|
||||||
const ResponsiveButton = ({ children, startIcon, size, style, ...props }) => {
|
const ResponsiveButton = ({ children, startIcon, endIcon, size, style, ...props }) => {
|
||||||
const isMobile = useMediaQuery((theme) => theme.breakpoints.down('sm'));
|
const isMobile = useMediaQuery((theme) => theme.breakpoints.down('sm'));
|
||||||
let newStyle = style || {};
|
let newStyle = style || {};
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
|
@ -10,8 +10,18 @@ const ResponsiveButton = ({ children, startIcon, size, style, ...props }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button className="responsive-button" size={isMobile ? 'large' : size} startIcon={isMobile ? null : startIcon} {...props} style={newStyle}>
|
<Button
|
||||||
{isMobile ? startIcon : children}
|
className="responsive-button"
|
||||||
|
size={isMobile ? 'large' : size}
|
||||||
|
startIcon={isMobile ? null : startIcon}
|
||||||
|
endIcon={isMobile ? null : endIcon}
|
||||||
|
{...props} style={newStyle}>
|
||||||
|
{(isMobile) ? startIcon : (
|
||||||
|
startIcon ? children : null
|
||||||
|
)}
|
||||||
|
{(isMobile) ? endIcon : (
|
||||||
|
endIcon ? children : null
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
62
client/src/components/terminal.jsx
Normal file
62
client/src/components/terminal.jsx
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Box, Button, Checkbox, CircularProgress, Input, Stack, TextField, Typography, useMediaQuery } from '@mui/material';
|
||||||
|
import * as API from '../api';
|
||||||
|
import LogLine from '../components/logLine';
|
||||||
|
import { useTheme } from '@emotion/react';
|
||||||
|
|
||||||
|
const Terminal = ({ logs, setLogs, fetchLogs, docker }) => {
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const [hasScrolled, setHasScrolled] = useState(false);
|
||||||
|
const [fetching, setFetching] = useState(false);
|
||||||
|
const theme = useTheme();
|
||||||
|
const isDark = theme.palette.mode === 'dark';
|
||||||
|
const screenMin = useMediaQuery((theme) => theme.breakpoints.up('sm'))
|
||||||
|
|
||||||
|
const bottomRef = useRef(null);
|
||||||
|
const terminalRef = useRef(null);
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
bottomRef.current.scrollIntoView({});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasScrolled) {
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
}, [logs]);
|
||||||
|
|
||||||
|
const handleScroll = (event) => {
|
||||||
|
if (event.target.scrollHeight - event.target.scrollTop === event.target.clientHeight) {
|
||||||
|
setHasScrolled(false);
|
||||||
|
}else {
|
||||||
|
setHasScrolled(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
ref={terminalRef}
|
||||||
|
sx={{
|
||||||
|
minHeight: '50px',
|
||||||
|
maxHeight: 'calc(1vh * 80 - 200px)',
|
||||||
|
overflow: 'auto',
|
||||||
|
padding: '10px',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
background: '#272d36',
|
||||||
|
color: '#fff',
|
||||||
|
borderTop: '3px solid ' + theme.palette.primary.main
|
||||||
|
}}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
>
|
||||||
|
{logs && logs.map((log, index) => (
|
||||||
|
<div key={index} style={{paddingTop: (!screenMin) ? '10px' : '2px'}}>
|
||||||
|
<LogLine message={log.output} docker isMobile={!screenMin} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{fetching && <CircularProgress sx={{ mt: 1, mb: 2 }} />}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Terminal;
|
|
@ -16,6 +16,7 @@ import Logs from './logs';
|
||||||
import DockerContainerSetup from './setup';
|
import DockerContainerSetup from './setup';
|
||||||
import NetworkContainerSetup from './network';
|
import NetworkContainerSetup from './network';
|
||||||
import VolumeContainerSetup from './volumes';
|
import VolumeContainerSetup from './volumes';
|
||||||
|
import DockerTerminal from './terminal';
|
||||||
|
|
||||||
const ContainerIndex = () => {
|
const ContainerIndex = () => {
|
||||||
const { containerName } = useParams();
|
const { containerName } = useParams();
|
||||||
|
@ -56,9 +57,7 @@ const ContainerIndex = () => {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Terminal',
|
title: 'Terminal',
|
||||||
children: <div>
|
children: <DockerTerminal refresh={refreshContainer} containerInfo={container} config={config}/>
|
||||||
<Alert severity="info">This feature is not yet implemented. It is planned for next version: 0.5.0</Alert>
|
|
||||||
</div>
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Links',
|
title: 'Links',
|
||||||
|
|
|
@ -32,6 +32,7 @@ const DockerContainerSetup = ({config, containerInfo, refresh}) => {
|
||||||
labels: Object.keys(containerInfo.Config.Labels).map((key) => {
|
labels: Object.keys(containerInfo.Config.Labels).map((key) => {
|
||||||
return { key, value: containerInfo.Config.Labels[key] };
|
return { key, value: containerInfo.Config.Labels[key] };
|
||||||
}),
|
}),
|
||||||
|
interactive: containerInfo.Config.Tty && containerInfo.Config.OpenStdin,
|
||||||
}}
|
}}
|
||||||
validate={(values) => {
|
validate={(values) => {
|
||||||
const errors = {};
|
const errors = {};
|
||||||
|
@ -65,6 +66,8 @@ const DockerContainerSetup = ({config, containerInfo, refresh}) => {
|
||||||
envVars: envVars,
|
envVars: envVars,
|
||||||
labels: labels,
|
labels: labels,
|
||||||
};
|
};
|
||||||
|
realvalues.interactive = realvalues.interactive ? 2 : 1;
|
||||||
|
|
||||||
return API.docker.updateContainer(containerInfo.Name.replace('/', ''), realvalues)
|
return API.docker.updateContainer(containerInfo.Name.replace('/', ''), realvalues)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setStatus({ success: true });
|
setStatus({ success: true });
|
||||||
|
@ -101,6 +104,12 @@ const DockerContainerSetup = ({config, containerInfo, refresh}) => {
|
||||||
options={restartPolicies}
|
options={restartPolicies}
|
||||||
formik={formik}
|
formik={formik}
|
||||||
/>
|
/>
|
||||||
|
<CosmosCheckbox
|
||||||
|
name="interactive"
|
||||||
|
label="Interactive Mode"
|
||||||
|
formik={formik}
|
||||||
|
/>
|
||||||
|
|
||||||
<CosmosFormDivider title={'Environment Variables'} />
|
<CosmosFormDivider title={'Environment Variables'} />
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
{formik.values.envVars.map((envVar, idx) => (
|
{formik.values.envVars.map((envVar, idx) => (
|
||||||
|
|
171
client/src/pages/servapps/containers/terminal.jsx
Normal file
171
client/src/pages/servapps/containers/terminal.jsx
Normal file
|
@ -0,0 +1,171 @@
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import * as API from '../../../api';
|
||||||
|
import { Alert, Input, Stack, useMediaQuery, useTheme } from '@mui/material';
|
||||||
|
import Terminal from '../../../components/terminal';
|
||||||
|
import { ApiOutlined, SendOutlined } from '@ant-design/icons';
|
||||||
|
import ResponsiveButton from '../../../components/responseiveButton';
|
||||||
|
|
||||||
|
const DockerTerminal = ({containerInfo, refresh}) => {
|
||||||
|
const { Name, Config, NetworkSettings, State } = containerInfo;
|
||||||
|
const isInteractive = Config.Tty;
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery((theme) => theme.breakpoints.down('sm'))
|
||||||
|
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [output, setOutput] = useState([
|
||||||
|
{
|
||||||
|
output: 'Not Connected.',
|
||||||
|
type: 'stdout'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
const ws = useRef(null);
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Enter' && e.ctrlKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeInteractive = () => {
|
||||||
|
API.docker.updateContainer(Name.slice(1), {interactive: 2})
|
||||||
|
.then(() => {
|
||||||
|
refresh && refresh();
|
||||||
|
}).catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
refresh && refresh();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const connect = (newProc) => {
|
||||||
|
if(ws.current) {
|
||||||
|
ws.current.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.current = newProc ?
|
||||||
|
API.docker.createTerminal(Name.slice(1))
|
||||||
|
: API.docker.attachTerminal(Name.slice(1));
|
||||||
|
|
||||||
|
ws.current.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
let data = JSON.parse(event.data);
|
||||||
|
setOutput((prevOutput) => [...prevOutput, ...data]);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("error", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.current.onclose = () => {
|
||||||
|
setIsConnected(false);
|
||||||
|
let terminalBoldRed = '\x1b[1;31m';
|
||||||
|
setOutput((prevOutput) => [...prevOutput,
|
||||||
|
{output: terminalBoldRed + 'Disconnected from ' + (newProc ? 'bash' : 'main process TTY'), type: 'stdout'}]);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.current.onopen = () => {
|
||||||
|
setIsConnected(true);
|
||||||
|
let terminalBoldGreen = '\x1b[1;32m';
|
||||||
|
setOutput((prevOutput) => [...prevOutput,
|
||||||
|
{output: terminalBoldGreen + 'Connected to ' + (newProc ? 'bash' : 'main process TTY'), type: 'stdout'}]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
setIsConnected(false);
|
||||||
|
ws.current.close();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
const sendMessage = () => {
|
||||||
|
if (ws.current) {
|
||||||
|
ws.current.send(message);
|
||||||
|
setMessage('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="terminal-container" onKeyDown={handleKeyDown}>
|
||||||
|
{(!isInteractive) && (
|
||||||
|
<Alert severity="warning">
|
||||||
|
This container is not interactive.
|
||||||
|
If you want to connect to the main process,
|
||||||
|
<Button onClick={() => makeInteractive()}>Enable TTY</Button>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Terminal
|
||||||
|
logs={output}
|
||||||
|
setLogs={setOutput}
|
||||||
|
docker
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Stack
|
||||||
|
direction="column"
|
||||||
|
spacing={1}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
sx={{
|
||||||
|
background: '#272d36',
|
||||||
|
color: '#fff',
|
||||||
|
padding: '10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '125%',
|
||||||
|
padding: '10px 0',
|
||||||
|
}}>
|
||||||
|
{
|
||||||
|
isConnected ? (
|
||||||
|
<ApiOutlined style={{color: '#00ff00'}} />
|
||||||
|
) : (
|
||||||
|
<ApiOutlined style={{color: '#ff0000'}} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
multiline
|
||||||
|
fullWidth
|
||||||
|
placeholder={isMobile ? "Enter command" : "Enter command (CTRL+Enter to send)"}
|
||||||
|
style={{
|
||||||
|
fontSize: '125%',
|
||||||
|
padding: '10px 10px',
|
||||||
|
background: 'rgba(0,0,0,0.1)',
|
||||||
|
}}
|
||||||
|
disableUnderline
|
||||||
|
disabled={!isConnected}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ResponsiveButton variant="outlined" disabled={!isConnected} onClick={sendMessage} endIcon={
|
||||||
|
<SendOutlined />
|
||||||
|
}>Send</ResponsiveButton>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={1}
|
||||||
|
>
|
||||||
|
<Button variant="outlined"
|
||||||
|
onClick={() => connect(false)}>Connect</Button>
|
||||||
|
<Button variant="outlined" onClick={() => connect(true)}>New Shell</Button>
|
||||||
|
{isConnected && (
|
||||||
|
<Button variant="outlined" onClick={() => ws.current.close()}>Disconnect</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
</Stack>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DockerTerminal;
|
1
go.mod
1
go.mod
|
@ -74,6 +74,7 @@ require (
|
||||||
github.com/googleapis/gax-go/v2 v2.0.5 // indirect
|
github.com/googleapis/gax-go/v2 v2.0.5 // indirect
|
||||||
github.com/gophercloud/gophercloud v0.15.0 // indirect
|
github.com/gophercloud/gophercloud v0.15.0 // indirect
|
||||||
github.com/gophercloud/utils v0.0.0-20210113034859-6f548432055a // indirect
|
github.com/gophercloud/utils v0.0.0-20210113034859-6f548432055a // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.0 // indirect
|
||||||
github.com/hashicorp/go-cleanhttp v0.5.1 // indirect
|
github.com/hashicorp/go-cleanhttp v0.5.1 // indirect
|
||||||
github.com/hashicorp/go-retryablehttp v0.6.8 // indirect
|
github.com/hashicorp/go-retryablehttp v0.6.8 // indirect
|
||||||
github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df // indirect
|
github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df // indirect
|
||||||
|
|
2
go.sum
2
go.sum
|
@ -326,6 +326,8 @@ github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2z
|
||||||
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||||
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||||
|
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||||
|
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/grpc-ecosystem/grpc-gateway v1.8.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
|
github.com/grpc-ecosystem/grpc-gateway v1.8.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
|
||||||
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
|
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
|
||||||
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
|
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "cosmos-server",
|
"name": "cosmos-server",
|
||||||
"version": "0.4.3",
|
"version": "0.5.0-unstable",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "test-server.js",
|
"main": "test-server.js",
|
||||||
"bugs": {
|
"bugs": {
|
||||||
|
|
|
@ -21,10 +21,10 @@ type LogOutput struct {
|
||||||
Output string `json:"output"`
|
Output string `json:"output"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseDockerLogHeader parses the first 8 bytes of a Docker log message
|
// ParseDockerLogHeader parses the first 8 bytes of a Docker log message
|
||||||
// and returns the stream type, size, and the rest of the message as output.
|
// and returns the stream type, size, and the rest of the message as output.
|
||||||
// It also checks if the message contains a log header and extracts the log message from it.
|
// It also checks if the message contains a log header and extracts the log message from it.
|
||||||
func parseDockerLogHeader(data []byte) (LogOutput) {
|
func ParseDockerLogHeader(data []byte) (LogOutput) {
|
||||||
var logOutput LogOutput
|
var logOutput LogOutput
|
||||||
logOutput.StreamType = 1 // assume stdout if header not present
|
logOutput.StreamType = 1 // assume stdout if header not present
|
||||||
logOutput.Size = uint32(len(data))
|
logOutput.Size = uint32(len(data))
|
||||||
|
@ -65,11 +65,11 @@ func FilterLogs(logReader io.Reader, searchQuery string, limit int) []LogOutput
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := scanner.Text()
|
line := scanner.Text()
|
||||||
|
|
||||||
if len(searchQuery) > 0 && !strings.Contains(strings.ToUpper(line), strings.ToUpper(searchQuery)) {
|
if len(searchQuery) > 3 && !strings.Contains(strings.ToUpper(line), strings.ToUpper(searchQuery)) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
logLines = append(logLines, parseDockerLogHeader(([]byte)(line)))
|
logLines = append(logLines, ParseDockerLogHeader(([]byte)(line)))
|
||||||
}
|
}
|
||||||
|
|
||||||
from := utils.Max(len(logLines)-limit, 0)
|
from := utils.Max(len(logLines)-limit, 0)
|
||||||
|
|
|
@ -25,8 +25,6 @@ func ManageContainerRoute(w http.ResponseWriter, req *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
vars := mux.Vars(req)
|
vars := mux.Vars(req)
|
||||||
containerName := utils.SanitizeSafe(vars["containerId"])
|
containerName := utils.SanitizeSafe(vars["containerId"])
|
||||||
// stop, start, restart, kill, remove, pause, unpause, recreate
|
// stop, start, restart, kill, remove, pause, unpause, recreate
|
||||||
|
|
194
src/docker/api_terminal.go
Normal file
194
src/docker/api_terminal.go
Normal file
|
@ -0,0 +1,194 @@
|
||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/docker/docker/api/types"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/azukaar/cosmos-server/src/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
var upgrader = websocket.Upgrader{
|
||||||
|
ReadBufferSize: 1024,
|
||||||
|
WriteBufferSize: 1024,
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitIntoChunks(input string, chunkSize int) [][]LogOutput {
|
||||||
|
lines := strings.Split(input, "\n")
|
||||||
|
var chunks [][]LogOutput
|
||||||
|
|
||||||
|
for i := 0; i < len(lines); i += chunkSize {
|
||||||
|
end := i + chunkSize
|
||||||
|
|
||||||
|
// Avoid going over the end of the array
|
||||||
|
if end > len(lines) {
|
||||||
|
end = len(lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
var chunk []LogOutput
|
||||||
|
for j := i; j < end; j++ {
|
||||||
|
chunk = append(chunk, ParseDockerLogHeader(([]byte)(
|
||||||
|
lines[j],
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
chunks = append(chunks, chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
func TerminalRoute(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if utils.AdminOnly(w, r) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Log("Attempting to attach container")
|
||||||
|
|
||||||
|
upgrader.ReadBufferSize = 1024 * 4 // Increase the buffer size as needed
|
||||||
|
// Upgrade initial GET request to a websocket
|
||||||
|
ws, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
utils.Error("Failed to set websocket upgrade: ", err)
|
||||||
|
http.Error(w, "Failed to set websocket upgrade: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer ws.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
errD := Connect()
|
||||||
|
if errD != nil {
|
||||||
|
utils.Error("ManageContainer", errD)
|
||||||
|
utils.HTTPError(w, "Internal server error: " + errD.Error(), http.StatusInternalServerError, "DS002")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
containerID := utils.SanitizeSafe(vars["containerId"])
|
||||||
|
|
||||||
|
if containerID == "" {
|
||||||
|
utils.Error("containerID is required: ", nil)
|
||||||
|
http.Error(w, "containerID is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
action := utils.Sanitize(vars["action"])
|
||||||
|
|
||||||
|
utils.Log("Attaching container " + containerID + " to websocket")
|
||||||
|
|
||||||
|
var resp types.HijackedResponse
|
||||||
|
|
||||||
|
if action == "new" {
|
||||||
|
execConfig := types.ExecConfig{
|
||||||
|
Tty: true,
|
||||||
|
AttachStdin: true,
|
||||||
|
AttachStdout: true,
|
||||||
|
AttachStderr: true,
|
||||||
|
Cmd: []string{"/bin/sh"},
|
||||||
|
}
|
||||||
|
|
||||||
|
execStart := types.ExecStartCheck{
|
||||||
|
Tty: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
execResp, errExec := DockerClient.ContainerExecCreate(ctx, containerID, execConfig)
|
||||||
|
if errExec != nil {
|
||||||
|
utils.Error("ContainerExecCreate failed: ", errExec)
|
||||||
|
http.Error(w, "ContainerExecCreate failed: "+errExec.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err = DockerClient.ContainerExecAttach(ctx, execResp.ID, execStart)
|
||||||
|
if err != nil {
|
||||||
|
utils.Error("ContainerExecAttach failed: ", err)
|
||||||
|
http.Error(w, "ContainerExecAttach failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Log("Created new shell and attached to it in container " + containerID)
|
||||||
|
} else {
|
||||||
|
options := types.ContainerAttachOptions{
|
||||||
|
Stream: true,
|
||||||
|
Stdin: true,
|
||||||
|
Stdout: true,
|
||||||
|
Stderr: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach to the container
|
||||||
|
resp, err = DockerClient.ContainerAttach(ctx, containerID, options)
|
||||||
|
if err != nil {
|
||||||
|
utils.Error("ContainerAttach failed: ", err)
|
||||||
|
http.Error(w, "ContainerAttach failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.Log("Attached to existing process in container " + containerID)
|
||||||
|
}
|
||||||
|
defer resp.Close()
|
||||||
|
|
||||||
|
utils.Log("Attached container " + containerID + " to websocket")
|
||||||
|
|
||||||
|
var WSChan = make(chan []byte, 1024*1024*4)
|
||||||
|
var DockerChan = make(chan []byte, 1024*1024*4)
|
||||||
|
|
||||||
|
// Start a goroutine to read from our websocket and write to the container
|
||||||
|
go (func() {
|
||||||
|
for {
|
||||||
|
utils.Debug("Waiting for message from websocket")
|
||||||
|
_, message, err := ws.ReadMessage()
|
||||||
|
utils.Debug("Got message from websocket")
|
||||||
|
if err != nil {
|
||||||
|
utils.Error("Failed to read from websocket: ", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
WSChan <- []byte((string)(message) + "\n")
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
// Start a goroutine to read from the container and write to our websocket
|
||||||
|
go (func() {
|
||||||
|
for {
|
||||||
|
buf := make([]byte, 1024*1024*4)
|
||||||
|
utils.Debug("Waiting for message from container")
|
||||||
|
n, err := resp.Reader.Read(buf)
|
||||||
|
utils.Debug("Got message from container")
|
||||||
|
if err != nil {
|
||||||
|
utils.Error("Failed to read from container: ", err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
DockerChan <- buf[:n]
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case message := <-WSChan:
|
||||||
|
utils.Debug("Writing message to container")
|
||||||
|
_, err := resp.Conn.Write(message)
|
||||||
|
if err != nil {
|
||||||
|
utils.Error("Failed to write to container: ", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
utils.Debug("Wrote message to container")
|
||||||
|
case message := <-DockerChan:
|
||||||
|
utils.Debug("Writing message to websocket")
|
||||||
|
|
||||||
|
messages := splitIntoChunks(string(message), 5)
|
||||||
|
for _, messageSplit := range messages {
|
||||||
|
messageJSON, err := json.Marshal(messageSplit)
|
||||||
|
if err != nil {
|
||||||
|
utils.Error("Failed to marshal message: ", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = ws.WriteMessage(websocket.TextMessage, messageJSON)
|
||||||
|
if err != nil {
|
||||||
|
utils.Error("Failed to write to websocket: ", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
utils.Debug("Wrote message to websocket")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -19,6 +19,8 @@ type ContainerForm struct {
|
||||||
Labels map[string]string `json:"labels"`
|
Labels map[string]string `json:"labels"`
|
||||||
PortBindings nat.PortMap `json:"portBindings"`
|
PortBindings nat.PortMap `json:"portBindings"`
|
||||||
Volumes []mount.Mount `json:"Volumes"`
|
Volumes []mount.Mount `json:"Volumes"`
|
||||||
|
// we make this a int so that we can ignore 0
|
||||||
|
Interactive int `json:"interactive"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func UpdateContainerRoute(w http.ResponseWriter, req *http.Request) {
|
func UpdateContainerRoute(w http.ResponseWriter, req *http.Request) {
|
||||||
|
@ -84,6 +86,10 @@ func UpdateContainerRoute(w http.ResponseWriter, req *http.Request) {
|
||||||
container.HostConfig.Mounts = form.Volumes
|
container.HostConfig.Mounts = form.Volumes
|
||||||
container.HostConfig.Binds = []string{}
|
container.HostConfig.Binds = []string{}
|
||||||
}
|
}
|
||||||
|
if(form.Interactive != 0) {
|
||||||
|
container.Config.Tty = form.Interactive == 2
|
||||||
|
container.Config.OpenStdin = form.Interactive == 2
|
||||||
|
}
|
||||||
|
|
||||||
_, err = EditContainer(container.ID, container)
|
_, err = EditContainer(container.ID, container)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -227,11 +227,13 @@ func StartServer() {
|
||||||
srapi.HandleFunc("/api/servapps/{containerId}/manage/{action}", docker.ManageContainerRoute)
|
srapi.HandleFunc("/api/servapps/{containerId}/manage/{action}", docker.ManageContainerRoute)
|
||||||
srapi.HandleFunc("/api/servapps/{containerId}/secure/{status}", docker.SecureContainerRoute)
|
srapi.HandleFunc("/api/servapps/{containerId}/secure/{status}", docker.SecureContainerRoute)
|
||||||
srapi.HandleFunc("/api/servapps/{containerId}/logs", docker.GetContainerLogsRoute)
|
srapi.HandleFunc("/api/servapps/{containerId}/logs", docker.GetContainerLogsRoute)
|
||||||
|
srapi.HandleFunc("/api/servapps/{containerId}/terminal/{action}", docker.TerminalRoute)
|
||||||
srapi.HandleFunc("/api/servapps/{containerId}/update", docker.UpdateContainerRoute)
|
srapi.HandleFunc("/api/servapps/{containerId}/update", docker.UpdateContainerRoute)
|
||||||
srapi.HandleFunc("/api/servapps/{containerId}/", docker.GetContainerRoute)
|
srapi.HandleFunc("/api/servapps/{containerId}/", docker.GetContainerRoute)
|
||||||
srapi.HandleFunc("/api/servapps/{containerId}/network/{networkId}", docker.NetworkContainerRoutes)
|
srapi.HandleFunc("/api/servapps/{containerId}/network/{networkId}", docker.NetworkContainerRoutes)
|
||||||
srapi.HandleFunc("/api/servapps/{containerId}/networks", docker.NetworkContainerRoutes)
|
srapi.HandleFunc("/api/servapps/{containerId}/networks", docker.NetworkContainerRoutes)
|
||||||
srapi.HandleFunc("/api/servapps", docker.ContainersRoute)
|
srapi.HandleFunc("/api/servapps", docker.ContainersRoute)
|
||||||
|
|
||||||
|
|
||||||
if(!config.HTTPConfig.AcceptAllInsecureHostname) {
|
if(!config.HTTPConfig.AcceptAllInsecureHostname) {
|
||||||
srapi.Use(utils.EnsureHostname)
|
srapi.Use(utils.EnsureHostname)
|
||||||
|
|
|
@ -14,6 +14,7 @@ export default defineConfig({
|
||||||
'/cosmos/api': {
|
'/cosmos/api': {
|
||||||
target: 'https://localhost:8443',
|
target: 'https://localhost:8443',
|
||||||
secure: false,
|
secure: false,
|
||||||
|
ws: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue