mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: show listening ports in port forward popup (#4389)
* feat: show listening ports in port forward popup * Move fetch logic to a machine * feat: don't show app ports and common non-HTTP ports Co-authored-by: Bruno Quaresma <bruno@coder.com>
This commit is contained in:
co-authored by
Bruno Quaresma
parent
a64731eea5
commit
b1a095e486
@@ -9,6 +9,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -262,6 +263,59 @@ func (api *API) workspaceAgentListeningPorts(rw http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
|
||||
// Get a list of ports that are in-use by applications.
|
||||
apps, err := api.Database.GetWorkspaceAppsByAgentID(ctx, workspaceAgent.ID)
|
||||
if xerrors.Is(err, sql.ErrNoRows) {
|
||||
apps = []database.WorkspaceApp{}
|
||||
err = nil
|
||||
}
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching workspace apps.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
appPorts := make(map[uint16]struct{}, len(apps))
|
||||
for _, app := range apps {
|
||||
if !app.Url.Valid || app.Url.String == "" {
|
||||
continue
|
||||
}
|
||||
u, err := url.Parse(app.Url.String)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
port := u.Port()
|
||||
if port == "" {
|
||||
continue
|
||||
}
|
||||
portNum, err := strconv.Atoi(port)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if portNum < 1 || portNum > 65535 {
|
||||
continue
|
||||
}
|
||||
appPorts[uint16(portNum)] = struct{}{}
|
||||
}
|
||||
|
||||
// Filter out ports that are globally blocked, in-use by applications, or
|
||||
// common non-HTTP ports such as databases, FTP, SSH, etc.
|
||||
filteredPorts := make([]codersdk.ListeningPort, 0, len(portsResponse.Ports))
|
||||
for _, port := range portsResponse.Ports {
|
||||
if port.Port < uint16(codersdk.MinimumListeningPort) {
|
||||
continue
|
||||
}
|
||||
if _, ok := appPorts[port.Port]; ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := codersdk.IgnoredListeningPorts[port.Port]; ok {
|
||||
continue
|
||||
}
|
||||
filteredPorts = append(filteredPorts, port)
|
||||
}
|
||||
|
||||
portsResponse.Ports = filteredPorts
|
||||
httpapi.Write(ctx, rw, http.StatusOK, portsResponse)
|
||||
}
|
||||
|
||||
|
||||
+202
-82
@@ -4,6 +4,7 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"runtime"
|
||||
"strconv"
|
||||
@@ -367,50 +368,124 @@ func TestWorkspaceAgentPTY(t *testing.T) {
|
||||
|
||||
func TestWorkspaceAgentListeningPorts(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: true,
|
||||
})
|
||||
coderdPort, err := strconv.Atoi(client.URL.Port())
|
||||
require.NoError(t, err)
|
||||
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
authToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionDryRun: echo.ProvisionComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "example",
|
||||
Type: "aws_instance",
|
||||
Agents: []*proto.Agent{{
|
||||
Id: uuid.NewString(),
|
||||
Auth: &proto.Agent_Token{
|
||||
Token: authToken,
|
||||
},
|
||||
setup := func(t *testing.T, apps []*proto.App) (*codersdk.Client, uint16, uuid.UUID) {
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: true,
|
||||
})
|
||||
coderdPort, err := strconv.Atoi(client.URL.Port())
|
||||
require.NoError(t, err)
|
||||
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
authToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionDryRun: echo.ProvisionComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "example",
|
||||
Type: "aws_instance",
|
||||
Agents: []*proto.Agent{{
|
||||
Id: uuid.NewString(),
|
||||
Auth: &proto.Agent_Token{
|
||||
Token: authToken,
|
||||
},
|
||||
Apps: apps,
|
||||
}},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
})
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
}},
|
||||
})
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient.SessionToken = authToken
|
||||
agentCloser := agent.New(agent.Options{
|
||||
FetchMetadata: agentClient.WorkspaceAgentMetadata,
|
||||
CoordinatorDialer: agentClient.ListenWorkspaceAgentTailnet,
|
||||
Logger: slogtest.Make(t, nil).Named("agent").Leveled(slog.LevelDebug),
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
_ = agentCloser.Close()
|
||||
})
|
||||
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient.SessionToken = authToken
|
||||
agentCloser := agent.New(agent.Options{
|
||||
FetchMetadata: agentClient.WorkspaceAgentMetadata,
|
||||
CoordinatorDialer: agentClient.ListenWorkspaceAgentTailnet,
|
||||
Logger: slogtest.Make(t, nil).Named("agent").Leveled(slog.LevelDebug),
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
_ = agentCloser.Close()
|
||||
})
|
||||
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
|
||||
return client, uint16(coderdPort), resources[0].Agents[0].ID
|
||||
}
|
||||
|
||||
willFilterPort := func(port int) bool {
|
||||
if port < codersdk.MinimumListeningPort || port > 65535 {
|
||||
return true
|
||||
}
|
||||
if _, ok := codersdk.IgnoredListeningPorts[uint16(port)]; ok {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
generateUnfilteredPort := func(t *testing.T) (net.Listener, uint16) {
|
||||
var (
|
||||
l net.Listener
|
||||
port uint16
|
||||
)
|
||||
require.Eventually(t, func() bool {
|
||||
var err error
|
||||
l, err = net.Listen("tcp", "localhost:0")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
tcpAddr, _ := l.Addr().(*net.TCPAddr)
|
||||
if willFilterPort(tcpAddr.Port) {
|
||||
_ = l.Close()
|
||||
return false
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = l.Close()
|
||||
})
|
||||
|
||||
port = uint16(tcpAddr.Port)
|
||||
return true
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
|
||||
return l, port
|
||||
}
|
||||
|
||||
generateFilteredPort := func(t *testing.T) (net.Listener, uint16) {
|
||||
var (
|
||||
l net.Listener
|
||||
port uint16
|
||||
)
|
||||
require.Eventually(t, func() bool {
|
||||
for ignoredPort := range codersdk.IgnoredListeningPorts {
|
||||
if ignoredPort < 1024 || ignoredPort == 5432 {
|
||||
continue
|
||||
}
|
||||
|
||||
var err error
|
||||
l, err = net.Listen("tcp", fmt.Sprintf("localhost:%d", ignoredPort))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = l.Close()
|
||||
})
|
||||
|
||||
port = ignoredPort
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
|
||||
return l, port
|
||||
}
|
||||
|
||||
t.Run("LinuxAndWindows", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -419,55 +494,98 @@ func TestWorkspaceAgentListeningPorts(t *testing.T) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create a TCP listener on a random port that we expect to see in the
|
||||
// response.
|
||||
l, err := net.Listen("tcp", "localhost:0")
|
||||
require.NoError(t, err)
|
||||
defer l.Close()
|
||||
tcpAddr, _ := l.Addr().(*net.TCPAddr)
|
||||
client, coderdPort, agentID := setup(t, nil)
|
||||
|
||||
// List ports and ensure that the port we expect to see is there.
|
||||
res, err := client.WorkspaceAgentListeningPorts(ctx, resources[0].Agents[0].ID)
|
||||
require.NoError(t, err)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
var (
|
||||
expected = map[uint16]bool{
|
||||
// expect the listener we made
|
||||
uint16(tcpAddr.Port): false,
|
||||
// expect the coderdtest server
|
||||
uint16(coderdPort): false,
|
||||
// Generate a random unfiltered port.
|
||||
l, lPort := generateUnfilteredPort(t)
|
||||
|
||||
// List ports and ensure that the port we expect to see is there.
|
||||
res, err := client.WorkspaceAgentListeningPorts(ctx, agentID)
|
||||
require.NoError(t, err)
|
||||
|
||||
var (
|
||||
expected = map[uint16]bool{
|
||||
// expect the listener we made
|
||||
lPort: false,
|
||||
// expect the coderdtest server
|
||||
coderdPort: false,
|
||||
}
|
||||
)
|
||||
for _, port := range res.Ports {
|
||||
if port.Network == codersdk.ListeningPortNetworkTCP {
|
||||
if val, ok := expected[port.Port]; ok {
|
||||
if val {
|
||||
t.Fatalf("expected to find TCP port %d only once in response", port.Port)
|
||||
}
|
||||
}
|
||||
expected[port.Port] = true
|
||||
}
|
||||
}
|
||||
)
|
||||
for _, port := range res.Ports {
|
||||
if port.Network == codersdk.ListeningPortNetworkTCP {
|
||||
if val, ok := expected[port.Port]; ok {
|
||||
if val {
|
||||
t.Fatalf("expected to find TCP port %d only once in response", port.Port)
|
||||
for port, found := range expected {
|
||||
if !found {
|
||||
t.Fatalf("expected to find TCP port %d in response", port)
|
||||
}
|
||||
}
|
||||
|
||||
// Close the listener and check that the port is no longer in the response.
|
||||
require.NoError(t, l.Close())
|
||||
time.Sleep(2 * time.Second) // avoid cache
|
||||
res, err = client.WorkspaceAgentListeningPorts(ctx, agentID)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, port := range res.Ports {
|
||||
if port.Network == codersdk.ListeningPortNetworkTCP && port.Port == lPort {
|
||||
t.Fatalf("expected to not find TCP port %d in response", lPort)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Filter", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Generate an unfiltered port that we will create an app for and
|
||||
// should not exist in the response.
|
||||
_, appLPort := generateUnfilteredPort(t)
|
||||
app := &proto.App{
|
||||
Name: "test-app",
|
||||
Url: fmt.Sprintf("http://localhost:%d", appLPort),
|
||||
}
|
||||
|
||||
// Generate a filtered port that should not exist in the response.
|
||||
_, filteredLPort := generateFilteredPort(t)
|
||||
|
||||
client, coderdPort, agentID := setup(t, []*proto.App{app})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
res, err := client.WorkspaceAgentListeningPorts(ctx, agentID)
|
||||
require.NoError(t, err)
|
||||
|
||||
sawCoderdPort := false
|
||||
for _, port := range res.Ports {
|
||||
if port.Network == codersdk.ListeningPortNetworkTCP {
|
||||
if port.Port == appLPort {
|
||||
t.Fatalf("expected to not find TCP port (app port) %d in response", appLPort)
|
||||
}
|
||||
if port.Port == filteredLPort {
|
||||
t.Fatalf("expected to not find TCP port (filtered port) %d in response", filteredLPort)
|
||||
}
|
||||
if port.Port == coderdPort {
|
||||
sawCoderdPort = true
|
||||
}
|
||||
}
|
||||
expected[port.Port] = true
|
||||
}
|
||||
}
|
||||
for port, found := range expected {
|
||||
if !found {
|
||||
t.Fatalf("expected to find TCP port %d in response", port)
|
||||
if !sawCoderdPort {
|
||||
t.Fatalf("expected to find TCP port (coderd port) %d in response", coderdPort)
|
||||
}
|
||||
}
|
||||
|
||||
// Close the listener and check that the port is no longer in the response.
|
||||
require.NoError(t, l.Close())
|
||||
time.Sleep(2 * time.Second) // avoid cache
|
||||
res, err = client.WorkspaceAgentListeningPorts(ctx, resources[0].Agents[0].ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, port := range res.Ports {
|
||||
if port.Network == codersdk.ListeningPortNetworkTCP && port.Port == uint16(tcpAddr.Port) {
|
||||
t.Fatalf("expected to not find TCP port %d in response", tcpAddr.Port)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Darwin", func(t *testing.T) {
|
||||
@@ -477,6 +595,8 @@ func TestWorkspaceAgentListeningPorts(t *testing.T) {
|
||||
return
|
||||
}
|
||||
|
||||
client, _, agentID := setup(t, nil)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
@@ -486,7 +606,7 @@ func TestWorkspaceAgentListeningPorts(t *testing.T) {
|
||||
defer l.Close()
|
||||
|
||||
// List ports and ensure that the list is empty because we're on darwin.
|
||||
res, err := client.WorkspaceAgentListeningPorts(ctx, resources[0].Agents[0].ID)
|
||||
res, err := client.WorkspaceAgentListeningPorts(ctx, agentID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Ports, 0)
|
||||
})
|
||||
|
||||
@@ -9,7 +9,9 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
@@ -45,6 +47,76 @@ var (
|
||||
MinimumListeningPort = 9
|
||||
)
|
||||
|
||||
// IgnoredListeningPorts contains a list of ports in the global ignore list.
|
||||
// This list contains common TCP ports that are not HTTP servers, such as
|
||||
// databases, SSH, FTP, etc.
|
||||
//
|
||||
// This is implemented as a map for fast lookup.
|
||||
var IgnoredListeningPorts = map[uint16]struct{}{
|
||||
0: {},
|
||||
// Ports 1-8 are reserved for future use by the Coder agent.
|
||||
1: {},
|
||||
2: {},
|
||||
3: {},
|
||||
4: {},
|
||||
5: {},
|
||||
6: {},
|
||||
7: {},
|
||||
8: {},
|
||||
// ftp
|
||||
20: {},
|
||||
21: {},
|
||||
// ssh
|
||||
22: {},
|
||||
// telnet
|
||||
23: {},
|
||||
// smtp
|
||||
25: {},
|
||||
// dns over TCP
|
||||
53: {},
|
||||
// pop3
|
||||
110: {},
|
||||
// imap
|
||||
143: {},
|
||||
// bgp
|
||||
179: {},
|
||||
// ldap
|
||||
389: {},
|
||||
636: {},
|
||||
// smtps
|
||||
465: {},
|
||||
// smtp
|
||||
587: {},
|
||||
// ftps
|
||||
989: {},
|
||||
990: {},
|
||||
// imaps
|
||||
993: {},
|
||||
// pop3s
|
||||
995: {},
|
||||
// mysql
|
||||
3306: {},
|
||||
// rdp
|
||||
3389: {},
|
||||
// postgres
|
||||
5432: {},
|
||||
// mongodb
|
||||
27017: {},
|
||||
27018: {},
|
||||
27019: {},
|
||||
28017: {},
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Add a thousand more ports to the ignore list during tests so it's easier
|
||||
// to find an available port.
|
||||
if strings.HasSuffix(os.Args[0], ".test") {
|
||||
for i := 63000; i < 64000; i++ {
|
||||
IgnoredListeningPorts[uint16(i)] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ReconnectingPTYRequest is sent from the client to the server
|
||||
// to pipe data to a PTY.
|
||||
// @typescript-ignore ReconnectingPTYRequest
|
||||
|
||||
@@ -631,3 +631,12 @@ export const getWorkspaceQuota = async (
|
||||
const response = await axios.get(`/api/v2/workspace-quota/${userID}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getAgentListeningPorts = async (
|
||||
agentID: string,
|
||||
): Promise<TypesGen.ListeningPortsResponse> => {
|
||||
const response = await axios.get(
|
||||
`/api/v2/workspaceagents/${agentID}/listening-ports`,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -6,44 +6,58 @@ import TextField from "@material-ui/core/TextField"
|
||||
import OpenInNewOutlined from "@material-ui/icons/OpenInNewOutlined"
|
||||
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"
|
||||
import { Stack } from "components/Stack/Stack"
|
||||
import { useRef, useState } from "react"
|
||||
import { useRef, useState, Fragment } from "react"
|
||||
import { colors } from "theme/colors"
|
||||
import { CodeExample } from "../CodeExample/CodeExample"
|
||||
import {
|
||||
HelpTooltipLink,
|
||||
HelpTooltipLinksGroup,
|
||||
HelpTooltipText,
|
||||
HelpTooltipTitle,
|
||||
} from "../Tooltips/HelpTooltip"
|
||||
import { Maybe } from "components/Conditionals/Maybe"
|
||||
import { useMachine } from "@xstate/react"
|
||||
import { portForwardMachine } from "xServices/portForward/portForwardXService"
|
||||
|
||||
export interface PortForwardButtonProps {
|
||||
host: string
|
||||
username: string
|
||||
workspaceName: string
|
||||
agentName: string
|
||||
agentId: string
|
||||
}
|
||||
|
||||
const EnabledView: React.FC<PortForwardButtonProps> = (props) => {
|
||||
const { host, workspaceName, agentName, username } = props
|
||||
const { host, workspaceName, agentName, agentId, username } = props
|
||||
const styles = useStyles()
|
||||
const [port, setPort] = useState("3000")
|
||||
const { location } = window
|
||||
const urlExample = `${location.protocol}//${port}--${agentName}--${workspaceName}--${username}.${host}`
|
||||
const [state] = useMachine(portForwardMachine, {
|
||||
context: { agentId: agentId },
|
||||
})
|
||||
const ports = state.context.listeningPorts?.ports
|
||||
|
||||
return (
|
||||
<Stack direction="column" spacing={1}>
|
||||
<>
|
||||
<HelpTooltipText>
|
||||
Access ports running on the agent with the{" "}
|
||||
<strong>port, agent name, workspace name</strong> and{" "}
|
||||
<strong>your username</strong> URL schema, as shown below.
|
||||
</HelpTooltipText>
|
||||
|
||||
<CodeExample code={urlExample} />
|
||||
<CodeExample code={urlExample} className={styles.code} />
|
||||
|
||||
<HelpTooltipText>
|
||||
Use the form to open applications in a new tab.
|
||||
</HelpTooltipText>
|
||||
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1}
|
||||
alignItems="center"
|
||||
className={styles.form}
|
||||
>
|
||||
<TextField
|
||||
label="Port"
|
||||
type="number"
|
||||
@@ -64,29 +78,66 @@ const EnabledView: React.FC<PortForwardButtonProps> = (props) => {
|
||||
</Link>
|
||||
</Stack>
|
||||
|
||||
<Maybe condition={Boolean(ports && ports.length > 0)}>
|
||||
<HelpTooltipText>
|
||||
{ports &&
|
||||
ports.map((p, i) => {
|
||||
const url = `${location.protocol}//${p.port}--${agentName}--${workspaceName}--${username}.${host}`
|
||||
let label = `${p.port}`
|
||||
if (p.process_name) {
|
||||
label = `${p.process_name} - ${p.port}`
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && <span style={{ margin: "0 0.6em" }}>·</span>}
|
||||
<Link href={url} target="_blank" rel="noreferrer">
|
||||
{label}
|
||||
</Link>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</HelpTooltipText>
|
||||
</Maybe>
|
||||
|
||||
<HelpTooltipLinksGroup>
|
||||
<HelpTooltipLink href="https://coder.com/docs/coder-oss/latest/networking/port-forwarding#dashboard">
|
||||
Learn more about port forward
|
||||
Learn more about web port forwarding
|
||||
</HelpTooltipLink>
|
||||
</HelpTooltipLinksGroup>
|
||||
</Stack>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const DisabledView: React.FC<PortForwardButtonProps> = () => {
|
||||
const DisabledView: React.FC<PortForwardButtonProps> = ({
|
||||
workspaceName,
|
||||
agentName,
|
||||
}) => {
|
||||
const cliExample = `coder port-forward ${workspaceName}.${agentName} --tcp 3000`
|
||||
const styles = useStyles()
|
||||
|
||||
return (
|
||||
<Stack direction="column" spacing={1}>
|
||||
<>
|
||||
<HelpTooltipText>
|
||||
<strong>Your deployment does not have port forward enabled.</strong> See
|
||||
the docs for more details.
|
||||
<strong>
|
||||
Your deployment does not have web port forwarding enabled.
|
||||
</strong>{" "}
|
||||
See the docs for more details.
|
||||
</HelpTooltipText>
|
||||
|
||||
<HelpTooltipText>
|
||||
You can use the Coder CLI to forward ports from your workspace to your
|
||||
local machine, as shown below.
|
||||
</HelpTooltipText>
|
||||
|
||||
<CodeExample code={cliExample} className={styles.code} />
|
||||
|
||||
<HelpTooltipLinksGroup>
|
||||
<HelpTooltipLink href="https://coder.com/docs/coder-oss/latest/networking/port-forwarding#dashboard">
|
||||
Learn more about port forward
|
||||
Learn more about web port forwarding
|
||||
</HelpTooltipLink>
|
||||
</HelpTooltipLinksGroup>
|
||||
</Stack>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -128,6 +179,7 @@ export const PortForwardButton: React.FC<PortForwardButtonProps> = (props) => {
|
||||
horizontal: "left",
|
||||
}}
|
||||
>
|
||||
<HelpTooltipTitle>Port forward</HelpTooltipTitle>
|
||||
<ChooseOne>
|
||||
<Cond condition={host !== ""}>
|
||||
<EnabledView {...props} />
|
||||
@@ -146,7 +198,7 @@ const useStyles = makeStyles((theme) => ({
|
||||
padding: `${theme.spacing(2.5)}px ${theme.spacing(3.5)}px ${theme.spacing(
|
||||
3.5,
|
||||
)}px`,
|
||||
width: theme.spacing(46),
|
||||
width: theme.spacing(52),
|
||||
color: theme.palette.text.secondary,
|
||||
marginTop: theme.spacing(0.25),
|
||||
},
|
||||
@@ -161,4 +213,12 @@ const useStyles = makeStyles((theme) => ({
|
||||
borderColor: colors.gray[10],
|
||||
},
|
||||
},
|
||||
|
||||
code: {
|
||||
margin: theme.spacing(2, 0),
|
||||
},
|
||||
|
||||
form: {
|
||||
margin: theme.spacing(1.5, 0, 0),
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -179,6 +179,7 @@ export const Resources: FC<React.PropsWithChildren<ResourcesProps>> = ({
|
||||
<PortForwardButton
|
||||
host={applicationsHost}
|
||||
workspaceName={workspace.name}
|
||||
agentId={agent.id}
|
||||
agentName={agent.name}
|
||||
username={workspace.owner_name}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { getAgentListeningPorts } from "api/api"
|
||||
import { ListeningPortsResponse } from "api/typesGenerated"
|
||||
import { createMachine, assign } from "xstate"
|
||||
|
||||
export const portForwardMachine = createMachine(
|
||||
{
|
||||
id: "portForwardMachine",
|
||||
schema: {
|
||||
context: {} as {
|
||||
agentId: string
|
||||
listeningPorts?: ListeningPortsResponse
|
||||
},
|
||||
services: {} as {
|
||||
getListeningPorts: {
|
||||
data: ListeningPortsResponse
|
||||
}
|
||||
},
|
||||
},
|
||||
tsTypes: {} as import("./portForwardXService.typegen").Typegen0,
|
||||
initial: "loading",
|
||||
states: {
|
||||
loading: {
|
||||
invoke: {
|
||||
src: "getListeningPorts",
|
||||
onDone: {
|
||||
target: "success",
|
||||
actions: ["assignListeningPorts"],
|
||||
},
|
||||
},
|
||||
},
|
||||
success: {
|
||||
type: "final",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
services: {
|
||||
getListeningPorts: ({ agentId }) => getAgentListeningPorts(agentId),
|
||||
},
|
||||
actions: {
|
||||
assignListeningPorts: assign({
|
||||
listeningPorts: (_, { data }) => data,
|
||||
}),
|
||||
},
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user