[release] version 0.5.0-unstable

This commit is contained in:
Yann Stepienik 2023-05-11 19:15:05 +01:00
parent b76f0650d8
commit 8b4d738c2e
16 changed files with 486 additions and 14 deletions

View file

@ -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 {
list,
get,
@ -174,4 +190,6 @@ export {
attachNetwork,
detachNetwork,
createVolume,
attachTerminal,
createTerminal,
};

View file

@ -56,7 +56,6 @@ const LogLine = ({ message, docker, isMobile }) => {
if(docker) {
let parts = html.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z/)
if(!parts) {
console.error('Could not parse log line', html)
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
let restString = html.replace(parts[0], '')

View file

@ -1,7 +1,7 @@
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'));
let newStyle = style || {};
if (isMobile) {
@ -10,8 +10,18 @@ const ResponsiveButton = ({ children, startIcon, size, style, ...props }) => {
}
return (
<Button className="responsive-button" size={isMobile ? 'large' : size} startIcon={isMobile ? null : startIcon} {...props} style={newStyle}>
{isMobile ? startIcon : children}
<Button
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>
);
}

View 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;

View file

@ -16,6 +16,7 @@ import Logs from './logs';
import DockerContainerSetup from './setup';
import NetworkContainerSetup from './network';
import VolumeContainerSetup from './volumes';
import DockerTerminal from './terminal';
const ContainerIndex = () => {
const { containerName } = useParams();
@ -56,9 +57,7 @@ const ContainerIndex = () => {
},
{
title: 'Terminal',
children: <div>
<Alert severity="info">This feature is not yet implemented. It is planned for next version: 0.5.0</Alert>
</div>
children: <DockerTerminal refresh={refreshContainer} containerInfo={container} config={config}/>
},
{
title: 'Links',

View file

@ -32,6 +32,7 @@ const DockerContainerSetup = ({config, containerInfo, refresh}) => {
labels: Object.keys(containerInfo.Config.Labels).map((key) => {
return { key, value: containerInfo.Config.Labels[key] };
}),
interactive: containerInfo.Config.Tty && containerInfo.Config.OpenStdin,
}}
validate={(values) => {
const errors = {};
@ -65,6 +66,8 @@ const DockerContainerSetup = ({config, containerInfo, refresh}) => {
envVars: envVars,
labels: labels,
};
realvalues.interactive = realvalues.interactive ? 2 : 1;
return API.docker.updateContainer(containerInfo.Name.replace('/', ''), realvalues)
.then((res) => {
setStatus({ success: true });
@ -101,6 +104,12 @@ const DockerContainerSetup = ({config, containerInfo, refresh}) => {
options={restartPolicies}
formik={formik}
/>
<CosmosCheckbox
name="interactive"
label="Interactive Mode"
formik={formik}
/>
<CosmosFormDivider title={'Environment Variables'} />
<Grid item xs={12}>
{formik.values.envVars.map((envVar, idx) => (

View 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
View file

@ -74,6 +74,7 @@ require (
github.com/googleapis/gax-go/v2 v2.0.5 // indirect
github.com/gophercloud/gophercloud v0.15.0 // 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-retryablehttp v0.6.8 // indirect
github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df // indirect

2
go.sum
View file

@ -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.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
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/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=

View file

@ -1,6 +1,6 @@
{
"name": "cosmos-server",
"version": "0.4.3",
"version": "0.5.0-unstable",
"description": "",
"main": "test-server.js",
"bugs": {

View file

@ -21,10 +21,10 @@ type LogOutput struct {
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.
// 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
logOutput.StreamType = 1 // assume stdout if header not present
logOutput.Size = uint32(len(data))
@ -65,11 +65,11 @@ func FilterLogs(logReader io.Reader, searchQuery string, limit int) []LogOutput
for scanner.Scan() {
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
}
logLines = append(logLines, parseDockerLogHeader(([]byte)(line)))
logLines = append(logLines, ParseDockerLogHeader(([]byte)(line)))
}
from := utils.Max(len(logLines)-limit, 0)

View file

@ -25,8 +25,6 @@ func ManageContainerRoute(w http.ResponseWriter, req *http.Request) {
return
}
vars := mux.Vars(req)
containerName := utils.SanitizeSafe(vars["containerId"])
// stop, start, restart, kill, remove, pause, unpause, recreate

194
src/docker/api_terminal.go Normal file
View 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")
}
}
}

View file

@ -19,6 +19,8 @@ type ContainerForm struct {
Labels map[string]string `json:"labels"`
PortBindings nat.PortMap `json:"portBindings"`
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) {
@ -84,6 +86,10 @@ func UpdateContainerRoute(w http.ResponseWriter, req *http.Request) {
container.HostConfig.Mounts = form.Volumes
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)
if err != nil {

View file

@ -227,11 +227,13 @@ func StartServer() {
srapi.HandleFunc("/api/servapps/{containerId}/manage/{action}", docker.ManageContainerRoute)
srapi.HandleFunc("/api/servapps/{containerId}/secure/{status}", docker.SecureContainerRoute)
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}/", docker.GetContainerRoute)
srapi.HandleFunc("/api/servapps/{containerId}/network/{networkId}", docker.NetworkContainerRoutes)
srapi.HandleFunc("/api/servapps/{containerId}/networks", docker.NetworkContainerRoutes)
srapi.HandleFunc("/api/servapps", docker.ContainersRoute)
if(!config.HTTPConfig.AcceptAllInsecureHostname) {
srapi.Use(utils.EnsureHostname)

View file

@ -14,6 +14,7 @@ export default defineConfig({
'/cosmos/api': {
target: 'https://localhost:8443',
secure: false,
ws: true,
}
}
}