mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 10:21:52 +00:00
Merge branch 'feat/preferred-port-design' of github.com:Start9Labs/start-os into feat/preferred-port-design
This commit is contained in:
301
agents/exver.md
Normal file
301
agents/exver.md
Normal file
@@ -0,0 +1,301 @@
|
||||
# exver — Extended Versioning
|
||||
|
||||
Extended semver supporting **downstream versioning** (wrapper updates independent of upstream) and **flavors** (package fork variants).
|
||||
|
||||
Two implementations exist:
|
||||
- **Rust crate** (`exver`) — used in `core/`. Source: https://github.com/Start9Labs/exver-rs
|
||||
- **TypeScript** (`sdk/base/lib/exver/index.ts`) — used in `sdk/` and `web/`
|
||||
|
||||
Both parse the same string format and agree on `satisfies` semantics.
|
||||
|
||||
## Version Format
|
||||
|
||||
An **ExtendedVersion** string looks like:
|
||||
|
||||
```
|
||||
[#flavor:]upstream:downstream
|
||||
```
|
||||
|
||||
- **upstream** — the original package version (semver-style: `1.2.3`, `1.2.3-beta.1`)
|
||||
- **downstream** — the StartOS wrapper version (incremented independently)
|
||||
- **flavor** — optional lowercase ASCII prefix for fork variants
|
||||
|
||||
Examples:
|
||||
- `1.2.3:0` — upstream 1.2.3, first downstream release
|
||||
- `1.2.3:2` — upstream 1.2.3, third downstream release
|
||||
- `#bitcoin:21.0:1` — bitcoin flavor, upstream 21.0, downstream 1
|
||||
- `1.0.0-rc.1:0` — upstream with prerelease tag
|
||||
|
||||
## Core Types
|
||||
|
||||
### `Version`
|
||||
|
||||
A semver-style version with arbitrary digit segments and optional prerelease.
|
||||
|
||||
**Rust:**
|
||||
```rust
|
||||
use exver::Version;
|
||||
|
||||
let v = Version::new([1, 2, 3], []); // 1.2.3
|
||||
let v = Version::new([1, 0], ["beta".into()]); // 1.0-beta
|
||||
let v: Version = "1.2.3".parse().unwrap();
|
||||
|
||||
v.number() // &[1, 2, 3]
|
||||
v.prerelease() // &[]
|
||||
```
|
||||
|
||||
**TypeScript:**
|
||||
```typescript
|
||||
const v = new Version([1, 2, 3], [])
|
||||
const v = Version.parse("1.2.3")
|
||||
|
||||
v.number // number[]
|
||||
v.prerelease // (string | number)[]
|
||||
v.compare(other) // 'greater' | 'equal' | 'less'
|
||||
v.compareForSort(other) // -1 | 0 | 1
|
||||
```
|
||||
|
||||
Default: `0`
|
||||
|
||||
### `ExtendedVersion`
|
||||
|
||||
The primary version type. Wraps upstream + downstream `Version` plus an optional flavor.
|
||||
|
||||
**Rust:**
|
||||
```rust
|
||||
use exver::ExtendedVersion;
|
||||
|
||||
let ev = ExtendedVersion::new(
|
||||
Version::new([1, 2, 3], []),
|
||||
Version::default(), // downstream = 0
|
||||
);
|
||||
let ev: ExtendedVersion = "1.2.3:0".parse().unwrap();
|
||||
|
||||
ev.flavor() // Option<&str>
|
||||
ev.upstream() // &Version
|
||||
ev.downstream() // &Version
|
||||
|
||||
// Builder methods (consuming):
|
||||
ev.with_flavor("bitcoin")
|
||||
ev.without_flavor()
|
||||
ev.map_upstream(|v| ...)
|
||||
ev.map_downstream(|v| ...)
|
||||
```
|
||||
|
||||
**TypeScript:**
|
||||
```typescript
|
||||
const ev = new ExtendedVersion(null, upstream, downstream)
|
||||
const ev = ExtendedVersion.parse("1.2.3:0")
|
||||
const ev = ExtendedVersion.parseEmver("1.2.3.4") // emver compat
|
||||
|
||||
ev.flavor // string | null
|
||||
ev.upstream // Version
|
||||
ev.downstream // Version
|
||||
|
||||
ev.compare(other) // 'greater' | 'equal' | 'less' | null
|
||||
ev.equals(other) // boolean
|
||||
ev.greaterThan(other) // boolean
|
||||
ev.lessThan(other) // boolean
|
||||
ev.incrementMajor() // new ExtendedVersion
|
||||
ev.incrementMinor() // new ExtendedVersion
|
||||
```
|
||||
|
||||
**Ordering:** Versions with different flavors are **not comparable** (`PartialOrd`/`compare` returns `None`/`null`).
|
||||
|
||||
Default: `0:0`
|
||||
|
||||
### `VersionString` (Rust only, StartOS wrapper)
|
||||
|
||||
Defined in `core/src/util/version.rs`. Caches the original string representation alongside the parsed `ExtendedVersion`. Used as the key type in registry version maps.
|
||||
|
||||
```rust
|
||||
use crate::util::VersionString;
|
||||
|
||||
let vs: VersionString = "1.2.3:0".parse().unwrap();
|
||||
let vs = VersionString::from(extended_version);
|
||||
|
||||
// Deref to ExtendedVersion:
|
||||
vs.satisfies(&range);
|
||||
vs.upstream();
|
||||
|
||||
// String access:
|
||||
vs.as_str(); // &str
|
||||
AsRef::<str>::as_ref(&vs);
|
||||
```
|
||||
|
||||
`Ord` is implemented with a total ordering — versions with different flavors are ordered by flavor name (unflavored sorts last).
|
||||
|
||||
### `VersionRange`
|
||||
|
||||
A predicate over `ExtendedVersion`. Supports comparison operators, boolean logic, and flavor constraints.
|
||||
|
||||
**Rust:**
|
||||
```rust
|
||||
use exver::VersionRange;
|
||||
|
||||
// Constructors:
|
||||
VersionRange::any() // matches everything
|
||||
VersionRange::none() // matches nothing
|
||||
VersionRange::exactly(ev) // = ev
|
||||
VersionRange::anchor(GTE, ev) // >= ev
|
||||
VersionRange::caret(ev) // ^ev (compatible changes)
|
||||
VersionRange::tilde(ev) // ~ev (patch-level changes)
|
||||
|
||||
// Combinators (smart — eagerly simplify):
|
||||
VersionRange::and(a, b) // a && b
|
||||
VersionRange::or(a, b) // a || b
|
||||
VersionRange::not(a) // !a
|
||||
|
||||
// Parsing:
|
||||
let r: VersionRange = ">=1.0.0:0".parse().unwrap();
|
||||
let r: VersionRange = "^1.2.3:0".parse().unwrap();
|
||||
let r: VersionRange = ">=1.0.0 <2.0.0".parse().unwrap(); // implicit AND
|
||||
let r: VersionRange = ">=1.0.0 || >=2.0.0".parse().unwrap();
|
||||
let r: VersionRange = "#bitcoin".parse().unwrap(); // flavor match
|
||||
let r: VersionRange = "*".parse().unwrap(); // any
|
||||
|
||||
// Monoid wrappers for folding:
|
||||
AnyRange // fold with or, empty = None
|
||||
AllRange // fold with and, empty = Any
|
||||
```
|
||||
|
||||
**TypeScript:**
|
||||
```typescript
|
||||
// Constructors:
|
||||
VersionRange.any()
|
||||
VersionRange.none()
|
||||
VersionRange.anchor('=', ev)
|
||||
VersionRange.anchor('>=', ev)
|
||||
VersionRange.anchor('^', ev) // ^ and ~ are first-class operators
|
||||
VersionRange.anchor('~', ev)
|
||||
VersionRange.flavor(null) // match unflavored versions
|
||||
VersionRange.flavor("bitcoin") // match #bitcoin versions
|
||||
|
||||
// Combinators — static (smart, variadic):
|
||||
VersionRange.and(a, b, c, ...)
|
||||
VersionRange.or(a, b, c, ...)
|
||||
|
||||
// Combinators — instance (not smart, just wrap):
|
||||
range.and(other)
|
||||
range.or(other)
|
||||
range.not()
|
||||
|
||||
// Parsing:
|
||||
VersionRange.parse(">=1.0.0:0")
|
||||
VersionRange.parseEmver(">=1.2.3.4") // emver compat
|
||||
|
||||
// Analysis (TS only):
|
||||
range.normalize() // canonical form (see below)
|
||||
range.satisfiable() // boolean
|
||||
range.intersects(other) // boolean
|
||||
```
|
||||
|
||||
**Checking satisfaction:**
|
||||
|
||||
```rust
|
||||
// Rust:
|
||||
version.satisfies(&range) // bool
|
||||
```
|
||||
```typescript
|
||||
// TypeScript:
|
||||
version.satisfies(range) // boolean
|
||||
range.satisfiedBy(version) // boolean (convenience)
|
||||
```
|
||||
|
||||
Also available on `Version` (wraps in `ExtendedVersion` with downstream=0).
|
||||
|
||||
When no operator is specified in a range string, `^` (caret) is the default.
|
||||
|
||||
## Operators
|
||||
|
||||
| Syntax | Rust | TS | Meaning |
|
||||
|--------|------|----|---------|
|
||||
| `=` | `EQ` | `'='` | Equal |
|
||||
| `!=` | `NEQ` | `'!='` | Not equal |
|
||||
| `>` | `GT` | `'>'` | Greater than |
|
||||
| `>=` | `GTE` | `'>='` | Greater than or equal |
|
||||
| `<` | `LT` | `'<'` | Less than |
|
||||
| `<=` | `LTE` | `'<='` | Less than or equal |
|
||||
| `^` | expanded to `And(GTE, LT)` | `'^'` | Compatible (first non-zero digit unchanged) |
|
||||
| `~` | expanded to `And(GTE, LT)` | `'~'` | Patch-level (minor unchanged) |
|
||||
|
||||
## Flavor Rules
|
||||
|
||||
- Versions with **different flavors** never satisfy comparison operators (except `!=`, which returns true)
|
||||
- `VersionRange::Flavor(Some("bitcoin"))` matches only `#bitcoin:*` versions
|
||||
- `VersionRange::Flavor(None)` matches only unflavored versions
|
||||
- Flavor constraints compose with `and`/`or`/`not` like any other range
|
||||
|
||||
## Reduction and Normalization
|
||||
|
||||
### Rust: `reduce()` (shallow)
|
||||
|
||||
`VersionRange::reduce(self) -> Self` re-applies smart constructor rules to one level of the AST. Useful for simplifying a node that was constructed directly (e.g. deserialized) rather than through the smart constructors.
|
||||
|
||||
**Smart constructor rules applied by `and`, `or`, `not`, and `reduce`:**
|
||||
|
||||
`and`:
|
||||
- `and(Any, b) → b`, `and(a, Any) → a`
|
||||
- `and(None, _) → None`, `and(_, None) → None`
|
||||
|
||||
`or`:
|
||||
- `or(Any, _) → Any`, `or(_, Any) → Any`
|
||||
- `or(None, b) → b`, `or(a, None) → a`
|
||||
|
||||
`not`:
|
||||
- `not(=v) → !=v`, `not(!=v) → =v`
|
||||
- `not(and(a, b)) → or(not(a), not(b))` (De Morgan)
|
||||
- `not(or(a, b)) → and(not(a), not(b))` (De Morgan)
|
||||
- `not(not(a)) → a`
|
||||
- `not(Any) → None`, `not(None) → Any`
|
||||
|
||||
### TypeScript: `normalize()` (deep, canonical)
|
||||
|
||||
`VersionRange.normalize(): VersionRange` in `sdk/base/lib/exver/index.ts` performs full normalization by converting the range AST into a canonical form. This is a deep operation that produces a semantically equivalent but simplified range.
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. **`tables()`** — Converts the VersionRange AST into truth tables (`VersionRangeTable`). Each table is a number line split at version boundary points, with boolean values for each segment indicating whether versions in that segment satisfy the range. Separate tables are maintained per flavor (and for flavor negations).
|
||||
|
||||
2. **`VersionRangeTable.zip(a, b, func)`** — Merges two tables by walking their boundary points in sorted order and applying a boolean function (`&&` or `||`) to combine segment values. Adjacent segments with the same boolean value are collapsed automatically.
|
||||
|
||||
3. **`VersionRangeTable.and/or/not`** — Table-level boolean operations. `and` computes the cross-product of flavor tables (since `#a && #b` for different flavors is unsatisfiable). `not` inverts all segment values.
|
||||
|
||||
4. **`VersionRangeTable.collapse()`** — Checks if a table is uniformly true or false across all flavors and segments. Returns `true`, `false`, or `null` (mixed).
|
||||
|
||||
5. **`VersionRangeTable.minterms()`** — Converts truth tables back into a VersionRange AST in [sum-of-products](https://en.wikipedia.org/wiki/Canonical_normal_form#Minterms) canonical form. Each `true` segment becomes a product term (conjunction of boundary constraints), and all terms are joined with `or`. Adjacent boundary points collapse into `=` anchors.
|
||||
|
||||
**Example:** `normalize` can simplify:
|
||||
- `>=1.0.0:0 && <=1.0.0:0` → `=1.0.0:0`
|
||||
- `>=2.0.0:0 || >=1.0.0:0` → `>=1.0.0:0`
|
||||
- `!(!>=1.0.0:0)` → `>=1.0.0:0`
|
||||
|
||||
**Also exposes:**
|
||||
- `satisfiable(): boolean` — returns `true` if there exists any version satisfying the range (checks if `collapse(tables())` is not `false`)
|
||||
- `intersects(other): boolean` — returns `true` if `and(this, other)` is satisfiable
|
||||
|
||||
## API Differences Between Rust and TypeScript
|
||||
|
||||
| | Rust | TypeScript |
|
||||
|-|------|------------|
|
||||
| **`^` / `~`** | Expanded at construction to `And(GTE, LT)` | First-class operator on `Anchor` |
|
||||
| **`not()`** | Static, eagerly simplifies (De Morgan, double negation) | Instance method, just wraps |
|
||||
| **`and()`/`or()`** | Binary static | Both binary instance and variadic static |
|
||||
| **Normalization** | `reduce()` — shallow, one AST level | `normalize()` — deep canonical form via truth tables |
|
||||
| **Satisfiability** | Not available | `satisfiable()` and `intersects(other)` |
|
||||
| **ExtendedVersion helpers** | `with_flavor()`, `without_flavor()`, `map_upstream()`, `map_downstream()` | `incrementMajor()`, `incrementMinor()`, `greaterThan()`, `lessThan()`, `equals()`, etc. |
|
||||
| **Monoid wrappers** | `AnyRange` (fold with `or`) and `AllRange` (fold with `and`) | Not present — use variadic static methods |
|
||||
| **`VersionString`** | Wrapper caching parsed + string form | Not present |
|
||||
| **Emver compat** | `From<emver::Version>` for `ExtendedVersion` | `ExtendedVersion.parseEmver()`, `VersionRange.parseEmver()` |
|
||||
|
||||
## Serde
|
||||
|
||||
All types serialize/deserialize as strings (requires `serde` feature, enabled in StartOS):
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.2.3:0",
|
||||
"targetVersion": ">=1.0.0:0 <2.0.0:0",
|
||||
"sourceVersion": "^0.3.0:0"
|
||||
}
|
||||
```
|
||||
@@ -18,7 +18,7 @@ use crate::s9pk::manifest::{LocaleString, Manifest};
|
||||
use crate::status::StatusInfo;
|
||||
use crate::util::DataUrl;
|
||||
use crate::util::serde::{Pem, is_partial_of};
|
||||
use crate::{ActionId, HealthCheckId, HostId, PackageId, ReplayId, ServiceInterfaceId};
|
||||
use crate::{ActionId, GatewayId, HealthCheckId, HostId, PackageId, ReplayId, ServiceInterfaceId};
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Serialize, TS)]
|
||||
#[ts(export)]
|
||||
@@ -381,6 +381,9 @@ pub struct PackageDataEntry {
|
||||
pub hosts: Hosts,
|
||||
#[ts(type = "string[]")]
|
||||
pub store_exposed_dependents: Vec<JsonPointer>,
|
||||
#[serde(default)]
|
||||
#[ts(type = "string | null")]
|
||||
pub outbound_gateway: Option<GatewayId>,
|
||||
}
|
||||
impl AsRef<PackageDataEntry> for PackageDataEntry {
|
||||
fn as_ref(&self) -> &PackageDataEntry {
|
||||
|
||||
@@ -116,6 +116,7 @@ impl Public {
|
||||
acme
|
||||
},
|
||||
dns: Default::default(),
|
||||
default_outbound: None,
|
||||
},
|
||||
status_info: ServerStatus {
|
||||
backup_progress: None,
|
||||
@@ -219,6 +220,9 @@ pub struct NetworkInfo {
|
||||
pub acme: BTreeMap<AcmeProvider, AcmeSettings>,
|
||||
#[serde(default)]
|
||||
pub dns: DnsSettings,
|
||||
#[serde(default)]
|
||||
#[ts(type = "string | null")]
|
||||
pub default_outbound: Option<GatewayId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)]
|
||||
@@ -238,39 +242,42 @@ pub struct DnsSettings {
|
||||
#[ts(export)]
|
||||
pub struct NetworkInterfaceInfo {
|
||||
pub name: Option<InternedString>,
|
||||
#[ts(skip)]
|
||||
pub public: Option<bool>,
|
||||
pub secure: Option<bool>,
|
||||
pub ip_info: Option<Arc<IpInfo>>,
|
||||
#[serde(default, rename = "type")]
|
||||
pub gateway_type: Option<GatewayType>,
|
||||
}
|
||||
impl NetworkInterfaceInfo {
|
||||
pub fn public(&self) -> bool {
|
||||
self.public.unwrap_or_else(|| {
|
||||
!self.ip_info.as_ref().map_or(true, |ip_info| {
|
||||
let ip4s = ip_info
|
||||
.subnets
|
||||
.iter()
|
||||
.filter_map(|ipnet| {
|
||||
if let IpAddr::V4(ip4) = ipnet.addr() {
|
||||
Some(ip4)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<BTreeSet<_>>();
|
||||
if !ip4s.is_empty() {
|
||||
return ip4s
|
||||
.iter()
|
||||
.all(|ip4| ip4.is_loopback() || ip4.is_private() || ip4.is_link_local());
|
||||
}
|
||||
ip_info.subnets.iter().all(|ipnet| {
|
||||
if let IpAddr::V6(ip6) = ipnet.addr() {
|
||||
ipv6_is_local(ip6)
|
||||
let ip4s = ip_info
|
||||
.subnets
|
||||
.iter()
|
||||
.filter_map(|ipnet| {
|
||||
if let IpAddr::V4(ip4) = ipnet.addr() {
|
||||
Some(ip4)
|
||||
} else {
|
||||
true
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<BTreeSet<_>>();
|
||||
if !ip4s.is_empty() {
|
||||
return ip4s
|
||||
.iter()
|
||||
.all(|ip4| ip4.is_loopback() || ip4.is_private() || ip4.is_link_local());
|
||||
}
|
||||
ip_info.subnets.iter().all(|ipnet| {
|
||||
if let IpAddr::V6(ip6) = ipnet.addr() {
|
||||
ipv6_is_local(ip6)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub fn secure(&self) -> bool {
|
||||
@@ -309,6 +316,15 @@ pub enum NetworkInterfaceType {
|
||||
Loopback,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, TS, clap::ValueEnum)]
|
||||
#[ts(export)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum GatewayType {
|
||||
#[default]
|
||||
InboundOutbound,
|
||||
OutboundOnly,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, HasModel, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[model = "Model<Self>"]
|
||||
|
||||
@@ -137,6 +137,7 @@ pub async fn install(
|
||||
json!({
|
||||
"id": id,
|
||||
"targetVersion": VersionRange::exactly(version.deref().clone()),
|
||||
"otherVersions": "none",
|
||||
}),
|
||||
RegistryUrlParams {
|
||||
registry: registry.clone(),
|
||||
@@ -484,7 +485,7 @@ pub async fn cli_install(
|
||||
let mut packages: GetPackageResponse = from_value(
|
||||
ctx.call_remote::<RegistryContext>(
|
||||
"package.get",
|
||||
json!({ "id": &id, "targetVersion": version, "sourceVersion": source_version }),
|
||||
json!({ "id": &id, "targetVersion": version, "sourceVersion": source_version, "otherVersions": "none" }),
|
||||
)
|
||||
.await?,
|
||||
)?;
|
||||
|
||||
@@ -910,13 +910,14 @@ async fn watch_ip(
|
||||
|
||||
write_to.send_if_modified(
|
||||
|m: &mut OrdMap<GatewayId, NetworkInterfaceInfo>| {
|
||||
let (name, public, secure, prev_wan_ip) = m
|
||||
let (name, public, secure, gateway_type, prev_wan_ip) = m
|
||||
.get(&iface)
|
||||
.map_or((None, None, None, None), |i| {
|
||||
.map_or((None, None, None, None, None), |i| {
|
||||
(
|
||||
i.name.clone(),
|
||||
i.public,
|
||||
i.secure,
|
||||
i.gateway_type,
|
||||
i.ip_info
|
||||
.as_ref()
|
||||
.and_then(|i| i.wan_ip),
|
||||
@@ -931,6 +932,7 @@ async fn watch_ip(
|
||||
public,
|
||||
secure,
|
||||
ip_info: Some(ip_info.clone()),
|
||||
gateway_type,
|
||||
},
|
||||
)
|
||||
.filter(|old| &old.ip_info == &Some(ip_info))
|
||||
|
||||
@@ -8,7 +8,7 @@ use ts_rs::TS;
|
||||
|
||||
use crate::GatewayId;
|
||||
use crate::context::{CliContext, RpcContext};
|
||||
use crate::db::model::public::{NetworkInterfaceInfo, NetworkInterfaceType};
|
||||
use crate::db::model::public::{GatewayType, NetworkInterfaceInfo, NetworkInterfaceType};
|
||||
use crate::net::host::all_hosts;
|
||||
use crate::prelude::*;
|
||||
use crate::util::Invoke;
|
||||
@@ -32,14 +32,19 @@ pub fn tunnel_api<C: Context>() -> ParentHandler<C> {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct AddTunnelParams {
|
||||
#[arg(help = "help.arg.tunnel-name")]
|
||||
name: InternedString,
|
||||
#[arg(help = "help.arg.wireguard-config")]
|
||||
config: String,
|
||||
#[arg(help = "help.arg.is-public")]
|
||||
public: bool,
|
||||
#[arg(help = "help.arg.gateway-type")]
|
||||
#[serde(default, rename = "type")]
|
||||
gateway_type: Option<GatewayType>,
|
||||
#[arg(help = "help.arg.set-as-default-outbound")]
|
||||
#[serde(default)]
|
||||
set_as_default_outbound: bool,
|
||||
}
|
||||
|
||||
fn sanitize_config(config: &str) -> String {
|
||||
@@ -64,7 +69,8 @@ pub async fn add_tunnel(
|
||||
AddTunnelParams {
|
||||
name,
|
||||
config,
|
||||
public,
|
||||
gateway_type,
|
||||
set_as_default_outbound,
|
||||
}: AddTunnelParams,
|
||||
) -> Result<GatewayId, Error> {
|
||||
let ifaces = ctx.net_controller.net_iface.watcher.subscribe();
|
||||
@@ -76,9 +82,10 @@ pub async fn add_tunnel(
|
||||
iface.clone(),
|
||||
NetworkInterfaceInfo {
|
||||
name: Some(name),
|
||||
public: Some(public),
|
||||
public: None,
|
||||
secure: None,
|
||||
ip_info: None,
|
||||
gateway_type,
|
||||
},
|
||||
);
|
||||
return true;
|
||||
@@ -120,6 +127,19 @@ pub async fn add_tunnel(
|
||||
|
||||
sub.recv().await;
|
||||
|
||||
if set_as_default_outbound {
|
||||
ctx.db
|
||||
.mutate(|db| {
|
||||
db.as_public_mut()
|
||||
.as_server_info_mut()
|
||||
.as_network_mut()
|
||||
.as_default_outbound_mut()
|
||||
.ser(&Some(iface.clone()))
|
||||
})
|
||||
.await
|
||||
.result?;
|
||||
}
|
||||
|
||||
Ok(iface)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::progress::{FullProgressTracker, ProgressUnits};
|
||||
use crate::registry::context::RegistryContext;
|
||||
use crate::registry::device_info::DeviceInfo;
|
||||
use crate::registry::package::index::{PackageIndex, PackageVersionInfo};
|
||||
use crate::s9pk::manifest::LocaleString;
|
||||
use crate::s9pk::merkle_archive::source::ArchiveSource;
|
||||
use crate::s9pk::v2::SIG_CONTEXT;
|
||||
use crate::util::VersionString;
|
||||
@@ -38,11 +39,11 @@ impl Default for PackageDetailLevel {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, TS)]
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export)]
|
||||
pub struct PackageInfoShort {
|
||||
pub release_notes: String,
|
||||
pub release_notes: LocaleString,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, TS, Parser, HasModel)]
|
||||
@@ -89,17 +90,20 @@ impl GetPackageResponse {
|
||||
|
||||
let lesser_versions: BTreeMap<_, _> = self
|
||||
.other_versions
|
||||
.as_ref()
|
||||
.clone()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|(v, _)| ***v < *version)
|
||||
.filter(|(v, _)| **v < *version)
|
||||
.collect();
|
||||
|
||||
if !lesser_versions.is_empty() {
|
||||
table.add_row(row![bc => "OLDER VERSIONS"]);
|
||||
table.add_row(row![bc => "VERSION", "RELEASE NOTES"]);
|
||||
for (version, info) in lesser_versions {
|
||||
table.add_row(row![AsRef::<str>::as_ref(version), &info.release_notes]);
|
||||
table.add_row(row![
|
||||
AsRef::<str>::as_ref(&version),
|
||||
&info.release_notes.localized()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +151,7 @@ fn get_matching_models(
|
||||
id,
|
||||
source_version,
|
||||
device_info,
|
||||
target_version,
|
||||
..
|
||||
}: &GetPackageParams,
|
||||
) -> Result<Vec<(PackageId, ExtendedVersion, Model<PackageVersionInfo>)>, Error> {
|
||||
@@ -165,26 +170,29 @@ fn get_matching_models(
|
||||
.as_entries()?
|
||||
.into_iter()
|
||||
.map(|(v, info)| {
|
||||
let ev = ExtendedVersion::from(v);
|
||||
Ok::<_, Error>(
|
||||
if source_version.as_ref().map_or(Ok(true), |source_version| {
|
||||
Ok::<_, Error>(
|
||||
source_version.satisfies(
|
||||
&info
|
||||
.as_source_version()
|
||||
.de()?
|
||||
.unwrap_or(VersionRange::any()),
|
||||
),
|
||||
)
|
||||
})? {
|
||||
if target_version.as_ref().map_or(true, |tv| ev.satisfies(tv))
|
||||
&& source_version.as_ref().map_or(Ok(true), |source_version| {
|
||||
Ok::<_, Error>(
|
||||
source_version.satisfies(
|
||||
&info
|
||||
.as_source_version()
|
||||
.de()?
|
||||
.unwrap_or(VersionRange::any()),
|
||||
),
|
||||
)
|
||||
})?
|
||||
{
|
||||
let mut info = info.clone();
|
||||
if let Some(device_info) = &device_info {
|
||||
if info.for_device(device_info)? {
|
||||
Some((k.clone(), ExtendedVersion::from(v), info))
|
||||
Some((k.clone(), ev, info))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
Some((k.clone(), ExtendedVersion::from(v), info))
|
||||
Some((k.clone(), ev, info))
|
||||
}
|
||||
} else {
|
||||
None
|
||||
@@ -207,12 +215,7 @@ pub async fn get_package(ctx: RegistryContext, params: GetPackageParams) -> Resu
|
||||
for (id, version, info) in get_matching_models(&peek.as_index().as_package(), ¶ms)? {
|
||||
let package_best = best.entry(id.clone()).or_default();
|
||||
let package_other = other.entry(id.clone()).or_default();
|
||||
if params
|
||||
.target_version
|
||||
.as_ref()
|
||||
.map_or(true, |v| version.satisfies(v))
|
||||
&& package_best.keys().all(|k| !(**k > version))
|
||||
{
|
||||
if package_best.keys().all(|k| !(**k > version)) {
|
||||
for worse_version in package_best
|
||||
.keys()
|
||||
.filter(|k| ***k < version)
|
||||
@@ -569,3 +572,42 @@ pub async fn cli_download(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_matching_info_short() {
|
||||
use crate::registry::package::index::PackageMetadata;
|
||||
use crate::s9pk::manifest::{Alerts, Description};
|
||||
use crate::util::DataUrl;
|
||||
|
||||
let lang_map = |s: &str| {
|
||||
LocaleString::LanguageMap([("en".into(), s.into())].into_iter().collect())
|
||||
};
|
||||
|
||||
let info = PackageVersionInfo {
|
||||
metadata: PackageMetadata {
|
||||
title: "Test Package".into(),
|
||||
icon: DataUrl::from_vec("image/png", vec![]),
|
||||
description: Description {
|
||||
short: lang_map("A short description"),
|
||||
long: lang_map("A longer description of the test package"),
|
||||
},
|
||||
release_notes: lang_map("Initial release"),
|
||||
git_hash: None,
|
||||
license: "MIT".into(),
|
||||
wrapper_repo: "https://github.com/example/wrapper".parse().unwrap(),
|
||||
upstream_repo: "https://github.com/example/upstream".parse().unwrap(),
|
||||
support_site: "https://example.com/support".parse().unwrap(),
|
||||
marketing_site: "https://example.com".parse().unwrap(),
|
||||
donation_url: None,
|
||||
docs_url: None,
|
||||
alerts: Alerts::default(),
|
||||
dependency_metadata: BTreeMap::new(),
|
||||
os_version: exver::Version::new([0, 3, 6], []),
|
||||
sdk_version: None,
|
||||
hardware_acceleration: false,
|
||||
},
|
||||
source_version: None,
|
||||
s9pks: Vec::new(),
|
||||
};
|
||||
from_value::<PackageInfoShort>(to_value(&info).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
@@ -259,6 +259,7 @@ impl ServiceMap {
|
||||
service_interfaces: Default::default(),
|
||||
hosts: Default::default(),
|
||||
store_exposed_dependents: Default::default(),
|
||||
outbound_gateway: None,
|
||||
},
|
||||
)?;
|
||||
};
|
||||
|
||||
@@ -10,13 +10,13 @@ A server is not a toy. It is a critical component of the computing paradigm, and
|
||||
|
||||
Start9 is paving new ground with StartOS, trying to create what most developers and IT professionals thought impossible; namely, an OS and user experience that affords a normal person the same independent control over their data and communications as an experienced Linux sysadmin.
|
||||
|
||||
The difficulty of our endeavor requires making mistakes; and our integrity and dedication to excellence require that we correct them. This means a willingness to discard bad ideas and broken parts, and if absolutely necessary, to tear it all down and start over. That is exactly what we did with StartOS v0.2.0 in 2020. It is what we did with StartOS v0.3.0 in 2022. And we are doing it now with StartOS v0.4.0 in 2025.
|
||||
The difficulty of our endeavor requires making mistakes; and our integrity and dedication to excellence require that we correct them. This means a willingness to discard bad ideas and broken parts, and if absolutely necessary, to tear it all down and start over. That is exactly what we did with StartOS v0.2.0 in 2020. It is what we did with StartOS v0.3.0 in 2022. And we are doing it now with StartOS v0.4.0 in 2026.
|
||||
|
||||
v0.4.0 is a complete rewrite of StartOS, almost nothing survived. After nearly six years of building StartOS, we believe that we have finally arrived at the correct architecture and foundation that will allow us to deliver on the promise of sovereign computing.
|
||||
|
||||
## Changelog
|
||||
|
||||
### Improved User interface
|
||||
### New User interface
|
||||
|
||||
We re-wrote the StartOS UI to be more performant, more intuitive, and better looking on both mobile and desktop. Enjoy.
|
||||
|
||||
@@ -28,6 +28,10 @@ StartOS v0.4.0 supports multiple languages and also makes it easy to add more la
|
||||
|
||||
Neither Docker nor Podman offer the reliability and flexibility needed for StartOS. Instead, v0.4.0 uses a nested container paradigm based on LXC for the outer container and Linux namespaces for sub containers. This architecture naturally supports multi container setups.
|
||||
|
||||
### Hardware Acceleration
|
||||
|
||||
Services can take advantage of (and require) the presence of certain hardware modules, such as Nvidia GPUs, for transcoding or inference purposes. For example, StartOS and Ollama can run natively on The Nvidia DGX Spark and take full advantage of the hardware/firmware stack to perform local inference against open source models.
|
||||
|
||||
### New S9PK archive format
|
||||
|
||||
The S9PK archive format has been overhauled to allow for signature verification of partial downloads, and allow direct mounting of container images without unpacking the s9pk.
|
||||
@@ -80,13 +84,13 @@ The new start-fs fuse module unifies file system expectations for various platfo
|
||||
|
||||
StartOS now uses Extended Versioning (Exver), which consists of three parts: (1) a Semver-compliant upstream version, (2) a Semver-compliant wrapper version, and (3) an optional "flavor" prefix. Flavors can be thought of as alternative implementations of services, where a user would only want one or the other installed, and data can feasibly be migrating between the two. Another common characteristic of flavors is that they satisfy the same API requirement of dependents, though this is not strictly necessary. A valid Exver looks something like this: `#knots:29.0:1.0-beta.1`. This would translate to "the first beta release of StartOS wrapper version 1.0 of Bitcoin Knots version 29.0".
|
||||
|
||||
### ACME
|
||||
### Let's Encrypt
|
||||
|
||||
StartOS now supports using ACME protocol to automatically obtain SSL/TLS certificates from widely trusted certificate authorities, such as Let's Encrypt, for your public domains. This means people visiting your public websites and APIs will not need to download and trust your server's Root CA.
|
||||
StartOS now supports Let's Encrypt to automatically obtain SSL/TLS certificates for public domains. This means people visiting your public websites and APIs will not need to download and trust your server's Root CA.
|
||||
|
||||
### Gateways
|
||||
|
||||
Gateways connect your server to the Internet. They process outbound traffic, and under certain conditions, they also permit inbound traffic. For example, your router is a gateway. It is now possible add gateways to StartOS, such as StartTunnel, in order to more granularly control how your installed services are exposed to the Internet.
|
||||
Gateways connect your server to the Internet, facilitating inbound and outbound traffic. Your router is a gateway. It is now possible to add Wireguard VPN gateways to your server to control how devices outside the LAN connect to your server and how your server connects out to the Internet.
|
||||
|
||||
### Static DNS Servers
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
{{ pkg.title }}
|
||||
</span>
|
||||
<span class="detail-description">
|
||||
{{ pkg.description.short }}
|
||||
{{ pkg.description.short | localize }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { SharedPipesModule, TickerComponent } from '@start9labs/shared'
|
||||
import { LocalizePipe, SharedPipesModule, TickerComponent } from '@start9labs/shared'
|
||||
import { ItemComponent } from './item.component'
|
||||
|
||||
@NgModule({
|
||||
declarations: [ItemComponent],
|
||||
exports: [ItemComponent],
|
||||
imports: [CommonModule, RouterModule, SharedPipesModule, TickerComponent],
|
||||
imports: [CommonModule, RouterModule, SharedPipesModule, TickerComponent, LocalizePipe],
|
||||
})
|
||||
export class ItemModule {}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
output,
|
||||
} from '@angular/core'
|
||||
import { MarketplacePkgBase } from '../../types'
|
||||
import { CopyService, i18nPipe } from '@start9labs/shared'
|
||||
import { CopyService, i18nPipe, LocalizePipe } from '@start9labs/shared'
|
||||
import { DatePipe } from '@angular/common'
|
||||
import { MarketplaceItemComponent } from './item.component'
|
||||
|
||||
@@ -71,7 +71,7 @@ import { MarketplaceItemComponent } from './item.component'
|
||||
<div class="background-border box-shadow-lg shadow-color-light">
|
||||
<div class="box-container">
|
||||
<h2 class="additional-detail-title">{{ 'Description' | i18n }}</h2>
|
||||
<p [innerHTML]="pkg().description.long"></p>
|
||||
<p [innerHTML]="pkg().description.long | localize"></p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
@@ -129,7 +129,7 @@ import { MarketplaceItemComponent } from './item.component'
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [MarketplaceItemComponent, DatePipe, i18nPipe],
|
||||
imports: [MarketplaceItemComponent, DatePipe, i18nPipe, LocalizePipe],
|
||||
})
|
||||
export class MarketplaceAboutComponent {
|
||||
readonly copyService = inject(CopyService)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { i18nPipe } from '@start9labs/shared'
|
||||
import { TuiButton, TuiDialogContext } from '@taiga-ui/core'
|
||||
import { injectContext } from '@taiga-ui/polymorpheus'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [TuiButton, i18nPipe],
|
||||
template: `
|
||||
<div class="animation-container">
|
||||
<div class="port">
|
||||
<div class="port-inner"></div>
|
||||
</div>
|
||||
<div class="usb-stick">
|
||||
<div class="usb-connector"></div>
|
||||
<div class="usb-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
{{
|
||||
'Remove USB stick or other installation media from your server' | i18n
|
||||
}}
|
||||
</p>
|
||||
<footer>
|
||||
<button tuiButton (click)="context.completeWith(true)">
|
||||
{{ 'Done' | i18n }}
|
||||
</button>
|
||||
</footer>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.animation-container {
|
||||
position: relative;
|
||||
width: 160px;
|
||||
height: 69px;
|
||||
}
|
||||
|
||||
.port {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 28px;
|
||||
height: 18px;
|
||||
background: var(--tui-background-neutral-1);
|
||||
border: 2px solid var(--tui-border-normal);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.port-inner {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
right: 3px;
|
||||
bottom: 3px;
|
||||
background: var(--tui-background-neutral-2);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.usb-stick {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
animation: slide-out 2s ease-in-out 0.5s infinite;
|
||||
left: 32px;
|
||||
}
|
||||
|
||||
.usb-connector {
|
||||
width: 20px;
|
||||
height: 12px;
|
||||
background: var(--tui-text-secondary);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.usb-body {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
background: var(--tui-status-info);
|
||||
border-radius: 2px 4px 4px 2px;
|
||||
}
|
||||
|
||||
@keyframes slide-out {
|
||||
0% {
|
||||
left: 32px;
|
||||
opacity: 0;
|
||||
}
|
||||
5% {
|
||||
left: 32px;
|
||||
opacity: 1;
|
||||
}
|
||||
80% {
|
||||
left: 130px;
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
left: 130px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 2rem;
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class RemoveMediaDialog {
|
||||
protected readonly context = injectContext<TuiDialogContext<boolean>>()
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import { ChangeDetectorRef, Component, inject } from '@angular/core'
|
||||
import {
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
HostListener,
|
||||
inject,
|
||||
} from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import {
|
||||
@@ -21,13 +26,14 @@ import {
|
||||
import { TuiDataListWrapper, TuiSelect, TuiTooltip } from '@taiga-ui/kit'
|
||||
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
|
||||
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||
import { filter } from 'rxjs'
|
||||
import { filter, Subscription } from 'rxjs'
|
||||
import { ApiService } from '../services/api.service'
|
||||
import { StateService } from '../services/state.service'
|
||||
import { PreserveOverwriteDialog } from '../components/preserve-overwrite.dialog'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
@if (!shuttingDown) {
|
||||
<section tuiCardLarge="compact">
|
||||
<header tuiHeader>
|
||||
<h2 tuiTitle>{{ 'Select Drives' | i18n }}</h2>
|
||||
@@ -132,6 +138,7 @@ import { PreserveOverwriteDialog } from '../components/preserve-overwrite.dialog
|
||||
}
|
||||
</footer>
|
||||
</section>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
.no-drives {
|
||||
@@ -176,6 +183,14 @@ export default class DrivesPage {
|
||||
|
||||
protected readonly mobile = inject(TUI_IS_MOBILE)
|
||||
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
onKeydown(event: KeyboardEvent) {
|
||||
if (event.ctrlKey && event.shiftKey && event.key === 'X') {
|
||||
event.preventDefault()
|
||||
this.shutdownServer()
|
||||
}
|
||||
}
|
||||
|
||||
readonly osDriveTooltip = this.i18n.transform(
|
||||
'The drive where the StartOS operating system will be installed.',
|
||||
)
|
||||
@@ -185,6 +200,8 @@ export default class DrivesPage {
|
||||
|
||||
drives: DiskInfo[] = []
|
||||
loading = true
|
||||
shuttingDown = false
|
||||
private dialogSub?: Subscription
|
||||
selectedOsDrive: DiskInfo | null = null
|
||||
selectedDataDrive: DiskInfo | null = null
|
||||
preserveData: boolean | null = null
|
||||
@@ -339,22 +356,18 @@ export default class DrivesPage {
|
||||
loader.unsubscribe()
|
||||
|
||||
// Show success dialog
|
||||
this.dialogs
|
||||
.openConfirm({
|
||||
this.dialogSub = this.dialogs
|
||||
.openAlert('StartOS has been installed successfully.', {
|
||||
label: 'Installation Complete!',
|
||||
size: 's',
|
||||
data: {
|
||||
content: 'StartOS has been installed successfully.',
|
||||
yes: 'Continue to Setup',
|
||||
no: 'Shutdown',
|
||||
},
|
||||
dismissible: false,
|
||||
closeable: true,
|
||||
data: { button: this.i18n.transform('Continue to Setup') },
|
||||
})
|
||||
.subscribe(continueSetup => {
|
||||
if (continueSetup) {
|
||||
.subscribe({
|
||||
complete: () => {
|
||||
this.navigateToNextStep(result.attach)
|
||||
} else {
|
||||
this.shutdownServer()
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (e: any) {
|
||||
loader.unsubscribe()
|
||||
@@ -372,10 +385,12 @@ export default class DrivesPage {
|
||||
}
|
||||
|
||||
private async shutdownServer() {
|
||||
this.dialogSub?.unsubscribe()
|
||||
const loader = this.loader.open('Beginning shutdown').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.shutdown()
|
||||
this.shuttingDown = true
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
|
||||
@@ -6,7 +6,12 @@ import {
|
||||
ViewChild,
|
||||
DOCUMENT,
|
||||
} from '@angular/core'
|
||||
import { DownloadHTMLService, ErrorService, i18nPipe } from '@start9labs/shared'
|
||||
import {
|
||||
DialogService,
|
||||
DownloadHTMLService,
|
||||
ErrorService,
|
||||
i18nPipe,
|
||||
} from '@start9labs/shared'
|
||||
import { TuiIcon, TuiLoader, TuiTitle } from '@taiga-ui/core'
|
||||
import { TuiAvatar } from '@taiga-ui/kit'
|
||||
import { TuiCardLarge, TuiCell, TuiHeader } from '@taiga-ui/layout'
|
||||
@@ -14,7 +19,9 @@ import { ApiService } from '../services/api.service'
|
||||
import { StateService } from '../services/state.service'
|
||||
import { DocumentationComponent } from '../components/documentation.component'
|
||||
import { MatrixComponent } from '../components/matrix.component'
|
||||
import { RemoveMediaDialog } from '../components/remove-media.dialog'
|
||||
import { SetupCompleteRes } from '../types'
|
||||
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
@@ -29,12 +36,8 @@ import { SetupCompleteRes } from '../types'
|
||||
@if (!stateService.kiosk) {
|
||||
<span tuiSubtitle>
|
||||
{{
|
||||
stateService.setupType === 'restore'
|
||||
? ('You can unplug your backup drive' | i18n)
|
||||
: stateService.setupType === 'transfer'
|
||||
? ('You can unplug your transfer drive' | i18n)
|
||||
: ('http://start.local was for setup only. It will no longer work.'
|
||||
| i18n)
|
||||
'http://start.local was for setup only. It will no longer work.'
|
||||
| i18n
|
||||
}}
|
||||
</span>
|
||||
}
|
||||
@@ -69,14 +72,15 @@ import { SetupCompleteRes } from '../types'
|
||||
tuiCell="l"
|
||||
[class.disabled]="!stateService.kiosk && !downloaded"
|
||||
[disabled]="(!stateService.kiosk && !downloaded) || usbRemoved"
|
||||
(click)="usbRemoved = true"
|
||||
(click)="removeMedia()"
|
||||
>
|
||||
<tui-avatar appearance="secondary" src="@tui.usb" />
|
||||
<div tuiTitle>
|
||||
{{ 'USB Removed' | i18n }}
|
||||
{{ 'Remove Installation Media' | i18n }}
|
||||
<div tuiSubtitle>
|
||||
{{
|
||||
'Remove the USB installation media from your server' | i18n
|
||||
'Remove USB stick or other installation media from your server'
|
||||
| i18n
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,6 +188,7 @@ export default class SuccessPage implements AfterViewInit {
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly downloadHtml = inject(DownloadHTMLService)
|
||||
private readonly dialogs = inject(DialogService)
|
||||
private readonly i18n = inject(i18nPipe)
|
||||
|
||||
readonly stateService = inject(StateService)
|
||||
@@ -225,6 +230,21 @@ export default class SuccessPage implements AfterViewInit {
|
||||
})
|
||||
}
|
||||
|
||||
removeMedia() {
|
||||
this.dialogs
|
||||
.openComponent<boolean>(
|
||||
new PolymorpheusComponent(RemoveMediaDialog),
|
||||
{
|
||||
size: 's',
|
||||
dismissible: false,
|
||||
closeable: false,
|
||||
},
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.usbRemoved = true
|
||||
})
|
||||
}
|
||||
|
||||
exitKiosk() {
|
||||
this.api.exit()
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export default {
|
||||
2: 'Aktualisieren',
|
||||
4: 'System',
|
||||
5: 'Allgemein',
|
||||
6: 'E-Mail',
|
||||
6: 'SMTP',
|
||||
7: 'Sicherung erstellen',
|
||||
8: 'Sicherung wiederherstellen',
|
||||
9: 'Zum Login gehen',
|
||||
@@ -100,6 +100,7 @@ export default {
|
||||
102: 'Verlassen',
|
||||
103: 'Sind Sie sicher?',
|
||||
104: 'Neues Netzwerk-Gateway',
|
||||
107: 'Onion-Domains',
|
||||
108: 'Öffentlich',
|
||||
109: 'privat',
|
||||
111: 'Keine Onion-Domains',
|
||||
@@ -384,8 +385,8 @@ export default {
|
||||
405: 'Verbunden',
|
||||
406: 'Vergessen',
|
||||
407: 'WiFi-Zugangsdaten',
|
||||
408: 'Veraltet',
|
||||
409: 'Die WLAN-Unterstützung wird in StartOS v0.4.1 entfernt. Wenn Sie keinen Zugriff auf Ethernet haben, können Sie einen WLAN-Extender verwenden, um sich mit dem lokalen Netzwerk zu verbinden und dann Ihren Server über Ethernet an den Extender anschließen. Bitte wenden Sie sich bei Fragen an den Start9-Support.',
|
||||
408: 'Mit verstecktem Netzwerk verbinden',
|
||||
409: 'Verbinden mit',
|
||||
410: 'Bekannte Netzwerke',
|
||||
411: 'Weitere Netzwerke',
|
||||
412: 'WiFi ist deaktiviert',
|
||||
@@ -639,13 +640,11 @@ export default {
|
||||
667: 'Einrichtung wird gestartet',
|
||||
670: 'Warten Sie 1–2 Minuten und aktualisieren Sie die Seite',
|
||||
672: 'Einrichtung abgeschlossen!',
|
||||
673: 'Sie können Ihr Backup-Laufwerk entfernen',
|
||||
674: 'Sie können Ihr Übertragungs-Laufwerk entfernen',
|
||||
675: 'http://start.local war nur für die Einrichtung gedacht. Es funktioniert nicht mehr.',
|
||||
676: 'Adressinformationen herunterladen',
|
||||
677: 'Enthält die permanente lokale Adresse Ihres Servers und die Root-CA',
|
||||
678: 'USB entfernt',
|
||||
679: 'Entfernen Sie das USB-Installationsmedium aus Ihrem Server',
|
||||
678: 'Installationsmedium entfernen',
|
||||
679: 'Entfernen Sie den USB-Stick oder andere Installationsmedien von Ihrem Server',
|
||||
680: 'Server neu starten',
|
||||
681: 'Warten, bis der Server wieder online ist',
|
||||
682: 'Server ist wieder online',
|
||||
@@ -680,4 +679,16 @@ export default {
|
||||
714: 'Installation abgeschlossen!',
|
||||
715: 'StartOS wurde erfolgreich installiert.',
|
||||
716: 'Weiter zur Einrichtung',
|
||||
717: '',
|
||||
718: '',
|
||||
719: '',
|
||||
720: '',
|
||||
721: '',
|
||||
722: '',
|
||||
723: '',
|
||||
724: '',
|
||||
725: '',
|
||||
726: '',
|
||||
727: '',
|
||||
728: '',
|
||||
} satisfies i18n
|
||||
|
||||
@@ -4,7 +4,7 @@ export const ENGLISH: Record<string, number> = {
|
||||
'Update': 2, // verb
|
||||
'System': 4, // as in, system preferences
|
||||
'General': 5, // as in, general settings
|
||||
'Email': 6,
|
||||
'SMTP': 6,
|
||||
'Create Backup': 7, // create a backup
|
||||
'Restore Backup': 8, // restore from backup
|
||||
'Go to login': 9,
|
||||
@@ -99,6 +99,7 @@ export const ENGLISH: Record<string, number> = {
|
||||
'Leave': 102,
|
||||
'Are you sure?': 103,
|
||||
'New gateway': 104, // as in, a network gateway
|
||||
'Tor Domains': 107,
|
||||
'public': 108,
|
||||
'private': 109,
|
||||
'No Tor domains': 111,
|
||||
@@ -383,8 +384,8 @@ export const ENGLISH: Record<string, number> = {
|
||||
'Connected': 405,
|
||||
'Forget': 406, // as in, delete or remove
|
||||
'WiFi Credentials': 407,
|
||||
'Deprecated': 408,
|
||||
'WiFi support will be removed in StartOS v0.4.1. If you do not have access to Ethernet, you can use a WiFi extender to connect to the local network, then connect your server to the extender via Ethernet. Please contact Start9 support with any questions or concerns.': 409,
|
||||
'Connect to hidden network': 408,
|
||||
'Connect to': 409, // followed by a network name, e.g. "Connect to MyWiFi?"
|
||||
'Known Networks': 410,
|
||||
'Other Networks': 411,
|
||||
'WiFi is disabled': 412,
|
||||
@@ -639,13 +640,11 @@ export const ENGLISH: Record<string, number> = {
|
||||
'Starting setup': 667,
|
||||
'Wait 1-2 minutes and refresh the page': 670,
|
||||
'Setup Complete!': 672,
|
||||
'You can unplug your backup drive': 673,
|
||||
'You can unplug your transfer drive': 674,
|
||||
'http://start.local was for setup only. It will no longer work.': 675,
|
||||
'Download Address Info': 676,
|
||||
"Contains your server's permanent local address and Root CA": 677,
|
||||
'USB Removed': 678,
|
||||
'Remove the USB installation media from your server': 679,
|
||||
'Remove Installation Media': 678,
|
||||
'Remove USB stick or other installation media from your server': 679,
|
||||
'Restart Server': 680,
|
||||
'Waiting for server to come back online': 681,
|
||||
'Server is back online': 682,
|
||||
@@ -680,4 +679,16 @@ export const ENGLISH: Record<string, number> = {
|
||||
'Installation Complete!': 714,
|
||||
'StartOS has been installed successfully.': 715,
|
||||
'Continue to Setup': 716,
|
||||
'Set Outbound Gateway': 717,
|
||||
'Current': 718,
|
||||
'System default)': 719,
|
||||
'Outbound Gateway': 720,
|
||||
'Select the gateway for outbound traffic': 721,
|
||||
'The type of gateway': 722,
|
||||
'Outbound Only': 723,
|
||||
'Set as default outbound': 724,
|
||||
'Route all outbound traffic through this gateway': 725,
|
||||
'Wireguard Config File': 726,
|
||||
'Inbound/Outbound': 727,
|
||||
'StartTunnel (Inbound/Outbound)': 728,
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export default {
|
||||
2: 'Actualizar',
|
||||
4: 'Sistema',
|
||||
5: 'General',
|
||||
6: 'Correo electrónico',
|
||||
6: 'SMTP',
|
||||
7: 'Crear copia de seguridad',
|
||||
8: 'Restaurar copia de seguridad',
|
||||
9: 'Ir a inicio de sesión',
|
||||
@@ -100,6 +100,7 @@ export default {
|
||||
102: 'Salir',
|
||||
103: '¿Estás seguro?',
|
||||
104: 'Nueva puerta de enlace de red',
|
||||
107: 'dominios onion',
|
||||
108: 'público',
|
||||
109: 'privado',
|
||||
111: 'Sin dominios onion',
|
||||
@@ -384,8 +385,8 @@ export default {
|
||||
405: 'Conectado',
|
||||
406: 'Olvidar',
|
||||
407: 'Credenciales WiFi',
|
||||
408: 'Obsoleto',
|
||||
409: 'El soporte para WiFi será eliminado en StartOS v0.4.1. Si no tienes acceso a Ethernet, puedes usar un extensor WiFi para conectarte a la red local y luego conectar tu servidor al extensor por Ethernet. Por favor, contacta al soporte de Start9 si tienes dudas o inquietudes.',
|
||||
408: 'Conectar a red oculta',
|
||||
409: 'Conectar a',
|
||||
410: 'Redes conocidas',
|
||||
411: 'Otras redes',
|
||||
412: 'WiFi está deshabilitado',
|
||||
@@ -639,13 +640,11 @@ export default {
|
||||
667: 'Iniciando configuración',
|
||||
670: 'Espere 1–2 minutos y actualice la página',
|
||||
672: '¡Configuración completa!',
|
||||
673: 'Puede desconectar su unidad de copia de seguridad',
|
||||
674: 'Puede desconectar su unidad de transferencia',
|
||||
675: 'http://start.local era solo para la configuración. Ya no funcionará.',
|
||||
676: 'Descargar información de direcciones',
|
||||
677: 'Contiene la dirección local permanente de su servidor y la CA raíz',
|
||||
678: 'USB retirado',
|
||||
679: 'Retire el medio de instalación USB de su servidor',
|
||||
678: 'Retirar medio de instalación',
|
||||
679: 'Retire la memoria USB u otro medio de instalación de su servidor',
|
||||
680: 'Reiniciar servidor',
|
||||
681: 'Esperando a que el servidor vuelva a estar en línea',
|
||||
682: 'El servidor ha vuelto a estar en línea',
|
||||
@@ -680,4 +679,16 @@ export default {
|
||||
714: '¡Instalación completada!',
|
||||
715: 'StartOS se ha instalado correctamente.',
|
||||
716: 'Continuar con la configuración',
|
||||
717: '',
|
||||
718: '',
|
||||
719: '',
|
||||
720: '',
|
||||
721: '',
|
||||
722: '',
|
||||
723: '',
|
||||
724: '',
|
||||
725: '',
|
||||
726: '',
|
||||
727: '',
|
||||
728: '',
|
||||
} satisfies i18n
|
||||
|
||||
@@ -5,7 +5,7 @@ export default {
|
||||
2: 'Mettre à jour',
|
||||
4: 'Système',
|
||||
5: 'Général',
|
||||
6: 'Email',
|
||||
6: 'SMTP',
|
||||
7: 'Créer une sauvegarde',
|
||||
8: 'Restaurer une sauvegarde',
|
||||
9: 'Se connecter',
|
||||
@@ -100,6 +100,7 @@ export default {
|
||||
102: 'Quitter',
|
||||
103: 'Êtes-vous sûr ?',
|
||||
104: 'Nouvelle passerelle réseau',
|
||||
107: 'domaine onion',
|
||||
108: 'public',
|
||||
109: 'privé',
|
||||
111: 'Aucune domaine onion',
|
||||
@@ -384,8 +385,8 @@ export default {
|
||||
405: 'Connecté',
|
||||
406: 'Oublier',
|
||||
407: 'Identifiants WiFi',
|
||||
408: 'Obsolète',
|
||||
409: 'Le support WiFi sera supprimé dans StartOS v0.4.1. Si vous n’avez pas accès à internet via Ethernet, vous pouvez utiliser un répéteur WiFi pour vous connecter au réseau local, puis brancher votre serveur sur le répéteur en Ethernet. Contactez le support Start9 pour toute question.',
|
||||
408: 'Se connecter à un réseau masqué',
|
||||
409: 'Se connecter à',
|
||||
410: 'Réseaux connus',
|
||||
411: 'Autres réseaux',
|
||||
412: 'Le WiFi est désactivé',
|
||||
@@ -639,13 +640,11 @@ export default {
|
||||
667: 'Démarrage de la configuration',
|
||||
670: 'Attendez 1 à 2 minutes puis actualisez la page',
|
||||
672: 'Configuration terminée !',
|
||||
673: 'Vous pouvez débrancher votre disque de sauvegarde',
|
||||
674: 'Vous pouvez débrancher votre disque de transfert',
|
||||
675: 'http://start.local était réservé à la configuration. Il ne fonctionnera plus.',
|
||||
676: 'Télécharger les informations d’adresse',
|
||||
677: 'Contient l’adresse locale permanente de votre serveur et la CA racine',
|
||||
678: 'USB retiré',
|
||||
679: 'Retirez le support d’installation USB de votre serveur',
|
||||
677: 'Contient l\u2019adresse locale permanente de votre serveur et la CA racine',
|
||||
678: 'Retirer le support d\u2019installation',
|
||||
679: 'Retirez la clé USB ou tout autre support d\u2019installation de votre serveur',
|
||||
680: 'Redémarrer le serveur',
|
||||
681: 'En attente du retour en ligne du serveur',
|
||||
682: 'Le serveur est de nouveau en ligne',
|
||||
@@ -680,4 +679,16 @@ export default {
|
||||
714: 'Installation terminée !',
|
||||
715: 'StartOS a été installé avec succès.',
|
||||
716: 'Continuer vers la configuration',
|
||||
717: '',
|
||||
718: '',
|
||||
719: '',
|
||||
720: '',
|
||||
721: '',
|
||||
722: '',
|
||||
723: '',
|
||||
724: '',
|
||||
725: '',
|
||||
726: '',
|
||||
727: '',
|
||||
728: '',
|
||||
} satisfies i18n
|
||||
|
||||
@@ -5,7 +5,7 @@ export default {
|
||||
2: 'Aktualizuj',
|
||||
4: 'Ustawienia',
|
||||
5: 'Ogólne',
|
||||
6: 'E-mail',
|
||||
6: 'SMTP',
|
||||
7: 'Utwórz kopię zapasową',
|
||||
8: 'Przywróć z kopii zapasowej',
|
||||
9: 'Przejdź do logowania',
|
||||
@@ -100,6 +100,7 @@ export default {
|
||||
102: 'Opuść',
|
||||
103: 'Czy jesteś pewien?',
|
||||
104: 'Nowa brama sieciowa',
|
||||
107: 'domeny onion',
|
||||
108: 'publiczny',
|
||||
109: 'prywatny',
|
||||
111: 'Brak domeny onion',
|
||||
@@ -384,8 +385,8 @@ export default {
|
||||
405: 'Połączono',
|
||||
406: 'Zapomnij',
|
||||
407: 'Dane logowania WiFi',
|
||||
408: 'Przestarzałe',
|
||||
409: 'Obsługa WiFi zostanie usunięta w StartOS v0.4.1. Jeśli nie masz dostępu do sieci Ethernet, możesz użyć wzmacniacza WiFi do połączenia z siecią lokalną, a następnie podłączyć serwer do wzmacniacza przez Ethernet. W razie pytań lub wątpliwości skontaktuj się z pomocą techniczną Start9.',
|
||||
408: 'Połącz z ukrytą siecią',
|
||||
409: 'Połącz z',
|
||||
410: 'Znane sieci',
|
||||
411: 'Inne sieci',
|
||||
412: 'WiFi jest wyłączone',
|
||||
@@ -639,13 +640,11 @@ export default {
|
||||
667: 'Rozpoczynanie konfiguracji',
|
||||
670: 'Poczekaj 1–2 minuty i odśwież stronę',
|
||||
672: 'Konfiguracja zakończona!',
|
||||
673: 'Możesz odłączyć dysk kopii zapasowej',
|
||||
674: 'Możesz odłączyć dysk transferowy',
|
||||
675: 'http://start.local służył tylko do konfiguracji. Nie będzie już działać.',
|
||||
676: 'Pobierz informacje adresowe',
|
||||
677: 'Zawiera stały lokalny adres serwera oraz główny urząd certyfikacji (Root CA)',
|
||||
678: 'USB usunięty',
|
||||
679: 'Usuń instalacyjny nośnik USB z serwera',
|
||||
678: 'Usuń nośnik instalacyjny',
|
||||
679: 'Usuń pamięć USB lub inny nośnik instalacyjny z serwera',
|
||||
680: 'Uruchom ponownie serwer',
|
||||
681: 'Oczekiwanie na ponowne połączenie serwera',
|
||||
682: 'Serwer jest ponownie online',
|
||||
@@ -680,4 +679,16 @@ export default {
|
||||
714: 'Instalacja zakończona!',
|
||||
715: 'StartOS został pomyślnie zainstalowany.',
|
||||
716: 'Przejdź do konfiguracji',
|
||||
717: '',
|
||||
718: '',
|
||||
719: '',
|
||||
720: '',
|
||||
721: '',
|
||||
722: '',
|
||||
723: '',
|
||||
724: '',
|
||||
725: '',
|
||||
726: '',
|
||||
727: '',
|
||||
728: '',
|
||||
} satisfies i18n
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { inject, Injectable, Pipe, PipeTransform } from '@angular/core'
|
||||
import { i18nService } from './i18n.service'
|
||||
import { I18N } from './i18n.providers'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
|
||||
@Pipe({
|
||||
@@ -9,8 +10,10 @@ import { T } from '@start9labs/start-sdk'
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LocalizePipe implements PipeTransform {
|
||||
private readonly i18nService = inject(i18nService)
|
||||
private readonly i18n = inject(I18N)
|
||||
|
||||
transform(string: T.LocaleString): string {
|
||||
this.i18n() // read signal to trigger change detection on language switch
|
||||
return this.i18nService.localize(string)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,8 +45,8 @@ export const mockTunnelData: TunnelData = {
|
||||
gateways: {
|
||||
eth0: {
|
||||
name: null,
|
||||
public: null,
|
||||
secure: null,
|
||||
type: null,
|
||||
ipInfo: {
|
||||
name: 'Wired Connection 1',
|
||||
scopeId: 1,
|
||||
|
||||
@@ -15,7 +15,6 @@ import { TuiBadge } from '@taiga-ui/kit'
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
clip-path: inset(0 round 0.75rem);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -6,12 +6,18 @@ import {
|
||||
inject,
|
||||
} from '@angular/core'
|
||||
import { toSignal } from '@angular/core/rxjs-interop'
|
||||
import { getPkgId, i18nPipe } from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import {
|
||||
ErrorService,
|
||||
getPkgId,
|
||||
i18nPipe,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
import { ISB, T } from '@start9labs/start-sdk'
|
||||
import { TuiCell } from '@taiga-ui/layout'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { map } from 'rxjs'
|
||||
import { firstValueFrom, map } from 'rxjs'
|
||||
import { ActionService } from 'src/app/services/action.service'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { StandardActionsService } from 'src/app/services/standard-actions.service'
|
||||
import { getManifest } from 'src/app/utils/get-package-data'
|
||||
@@ -20,6 +26,9 @@ import {
|
||||
PrimaryStatus,
|
||||
renderPkgStatus,
|
||||
} from 'src/app/services/pkg-status-rendering.service'
|
||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import { FormComponent } from 'src/app/routes/portal/components/form.component'
|
||||
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
|
||||
const INACTIVE: PrimaryStatus[] = [
|
||||
'installing',
|
||||
@@ -65,6 +74,12 @@ const ALLOWED_STATUSES: Record<T.AllowedStatuses, Set<string>> = {
|
||||
|
||||
<section class="g-card">
|
||||
<header>StartOS</header>
|
||||
<button
|
||||
tuiCell
|
||||
[action]="outboundGatewayAction()"
|
||||
[inactive]="inactive"
|
||||
(click)="openOutboundGatewayModal()"
|
||||
></button>
|
||||
<button
|
||||
tuiCell
|
||||
[action]="rebuild"
|
||||
@@ -95,66 +110,78 @@ const ALLOWED_STATUSES: Record<T.AllowedStatuses, Set<string>> = {
|
||||
export default class ServiceActionsRoute {
|
||||
private readonly actions = inject(ActionService)
|
||||
private readonly i18n = inject(i18nPipe)
|
||||
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
|
||||
private readonly formDialog = inject(FormDialogService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
|
||||
ungrouped: 'General' | 'Other' = 'General'
|
||||
|
||||
readonly service = inject(StandardActionsService)
|
||||
readonly package = toSignal(
|
||||
inject<PatchDB<DataModel>>(PatchDB)
|
||||
.watch$('packageData', getPkgId())
|
||||
.pipe(
|
||||
map(pkg => {
|
||||
const specialGroup = Object.values(pkg.actions).some(a => !!a.group)
|
||||
? 'Other'
|
||||
: 'General'
|
||||
const status = renderPkgStatus(pkg).primary
|
||||
return {
|
||||
status,
|
||||
icon: pkg.icon,
|
||||
manifest: getManifest(pkg),
|
||||
actions: Object.entries(pkg.actions)
|
||||
.filter(([_, action]) => action.visibility !== 'hidden')
|
||||
.map(([id, action]) => ({
|
||||
...action,
|
||||
id,
|
||||
group: action.group || specialGroup,
|
||||
visibility: ALLOWED_STATUSES[action.allowedStatuses].has(
|
||||
status,
|
||||
)
|
||||
? action.visibility
|
||||
: ({
|
||||
disabled: `${this.i18n.transform('Action can only be executed when service is')} ${this.i18n.transform(action.allowedStatuses === 'only-running' ? 'Running' : 'Stopped')?.toLowerCase()}`,
|
||||
} as T.ActionVisibility),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.group === specialGroup && b.group !== specialGroup)
|
||||
return 1
|
||||
if (b.group === specialGroup && a.group !== specialGroup)
|
||||
return -1
|
||||
this.patch.watch$('packageData', getPkgId()).pipe(
|
||||
map(pkg => {
|
||||
const specialGroup = Object.values(pkg.actions).some(a => !!a.group)
|
||||
? 'Other'
|
||||
: 'General'
|
||||
const status = renderPkgStatus(pkg).primary
|
||||
return {
|
||||
status,
|
||||
icon: pkg.icon,
|
||||
manifest: getManifest(pkg),
|
||||
outboundGateway: pkg.outboundGateway,
|
||||
actions: Object.entries(pkg.actions)
|
||||
.filter(([_, action]) => action.visibility !== 'hidden')
|
||||
.map(([id, action]) => ({
|
||||
...action,
|
||||
id,
|
||||
group: action.group || specialGroup,
|
||||
visibility: ALLOWED_STATUSES[action.allowedStatuses].has(status)
|
||||
? action.visibility
|
||||
: ({
|
||||
disabled: `${this.i18n.transform('Action can only be executed when service is')} ${this.i18n.transform(action.allowedStatuses === 'only-running' ? 'Running' : 'Stopped')?.toLowerCase()}`,
|
||||
} as T.ActionVisibility),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.group === specialGroup && b.group !== specialGroup) return 1
|
||||
if (b.group === specialGroup && a.group !== specialGroup)
|
||||
return -1
|
||||
|
||||
const groupCompare = a.group.localeCompare(b.group) // sort groups lexicographically
|
||||
if (groupCompare !== 0) return groupCompare
|
||||
const groupCompare = a.group.localeCompare(b.group) // sort groups lexicographically
|
||||
if (groupCompare !== 0) return groupCompare
|
||||
|
||||
return a.id.localeCompare(b.id) // sort actions within groups lexicographically
|
||||
})
|
||||
.reduce<
|
||||
Record<
|
||||
string,
|
||||
Array<T.ActionMetadata & { id: string; group: string }>
|
||||
>
|
||||
>((acc, action) => {
|
||||
const key = action.group
|
||||
if (!acc[key]) {
|
||||
acc[key] = []
|
||||
}
|
||||
acc[key].push(action)
|
||||
return acc
|
||||
}, {}),
|
||||
}
|
||||
}),
|
||||
),
|
||||
return a.id.localeCompare(b.id) // sort actions within groups lexicographically
|
||||
})
|
||||
.reduce<
|
||||
Record<
|
||||
string,
|
||||
Array<T.ActionMetadata & { id: string; group: string }>
|
||||
>
|
||||
>((acc, action) => {
|
||||
const key = action.group
|
||||
if (!acc[key]) {
|
||||
acc[key] = []
|
||||
}
|
||||
acc[key].push(action)
|
||||
return acc
|
||||
}, {}),
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
readonly outboundGatewayAction = computed(() => {
|
||||
const pkg = this.package()
|
||||
const gateway = pkg?.outboundGateway
|
||||
return {
|
||||
name: this.i18n.transform('Set Outbound Gateway')!,
|
||||
description: gateway
|
||||
? `${this.i18n.transform('Current')}: ${gateway}`
|
||||
: `${this.i18n.transform('Current')}: ${this.i18n.transform('System')}`,
|
||||
}
|
||||
})
|
||||
|
||||
readonly rebuild = {
|
||||
name: this.i18n.transform('Rebuild Service')!,
|
||||
description: this.i18n.transform(
|
||||
@@ -181,6 +208,71 @@ export default class ServiceActionsRoute {
|
||||
})
|
||||
}
|
||||
|
||||
async openOutboundGatewayModal() {
|
||||
const pkg = this.package()
|
||||
if (!pkg) return
|
||||
|
||||
const gateways = await firstValueFrom(
|
||||
this.patch.watch$('serverInfo', 'network', 'gateways'),
|
||||
)
|
||||
|
||||
const SYSTEM_KEY = 'system'
|
||||
|
||||
const options: Record<string, string> = {
|
||||
[SYSTEM_KEY]: this.i18n.transform('System default')!,
|
||||
}
|
||||
|
||||
Object.entries(gateways)
|
||||
.filter(
|
||||
([_, g]) =>
|
||||
!!g.ipInfo &&
|
||||
g.ipInfo.deviceType !== 'bridge' &&
|
||||
g.ipInfo.deviceType !== 'loopback',
|
||||
)
|
||||
.forEach(([id, g]) => {
|
||||
options[id] = g.name ?? g.ipInfo?.name ?? id
|
||||
})
|
||||
|
||||
const spec = ISB.InputSpec.of({
|
||||
gateway: ISB.Value.select({
|
||||
name: this.i18n.transform('Outbound Gateway'),
|
||||
description: this.i18n.transform(
|
||||
'Select the gateway for outbound traffic',
|
||||
),
|
||||
default: pkg.outboundGateway ?? SYSTEM_KEY,
|
||||
values: options,
|
||||
}),
|
||||
})
|
||||
|
||||
this.formDialog.open(FormComponent, {
|
||||
label: 'Set Outbound Gateway',
|
||||
data: {
|
||||
spec: await configBuilderToSpec(spec),
|
||||
buttons: [
|
||||
{
|
||||
text: this.i18n.transform('Save'),
|
||||
handler: async (input: typeof spec._TYPE) => {
|
||||
const loader = this.loader.open('Saving').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.setServiceOutbound({
|
||||
packageId: pkg.manifest.id,
|
||||
gateway: input.gateway === SYSTEM_KEY ? null : input.gateway,
|
||||
})
|
||||
return true
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
return false
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
protected readonly isInactive = computed(
|
||||
(pkg = this.package()) => !pkg || INACTIVE.includes(pkg.status),
|
||||
)
|
||||
|
||||
@@ -28,7 +28,7 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">
|
||||
{{ 'Back' | i18n }}
|
||||
</a>
|
||||
{{ 'Email' | i18n }}
|
||||
{{ 'SMTP' | i18n }}
|
||||
</ng-container>
|
||||
@if (form$ | async; as form) {
|
||||
<form [formGroup]="form">
|
||||
|
||||
@@ -15,6 +15,7 @@ import { GatewaysTableComponent } from './table.component'
|
||||
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
import { TitleDirective } from 'src/app/services/title.service'
|
||||
import { ISB } from '@start9labs/start-sdk'
|
||||
import { RR } from 'src/app/services/api/api.types'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
@@ -51,11 +52,6 @@ import { ISB } from '@start9labs/start-sdk'
|
||||
<gateways-table />
|
||||
</section>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
max-width: 64rem;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
@@ -85,8 +81,19 @@ export default class GatewaysComponent {
|
||||
default: null,
|
||||
placeholder: 'StartTunnel 1',
|
||||
}),
|
||||
type: ISB.Value.select({
|
||||
name: this.i18n.transform('Type'),
|
||||
description: this.i18n.transform('The type of gateway'),
|
||||
default: 'inbound-outbound',
|
||||
values: {
|
||||
'inbound-outbound': this.i18n.transform(
|
||||
'StartTunnel (Inbound/Outbound)',
|
||||
),
|
||||
'outbound-only': this.i18n.transform('Outbound Only'),
|
||||
},
|
||||
}),
|
||||
config: ISB.Value.union({
|
||||
name: this.i18n.transform('StartTunnel Config File'),
|
||||
name: this.i18n.transform('Wireguard Config File'),
|
||||
default: 'paste',
|
||||
variants: ISB.Variants.of({
|
||||
paste: {
|
||||
@@ -113,10 +120,17 @@ export default class GatewaysComponent {
|
||||
},
|
||||
}),
|
||||
}),
|
||||
setAsDefaultOutbound: ISB.Value.toggle({
|
||||
name: this.i18n.transform('Set as default outbound'),
|
||||
description: this.i18n.transform(
|
||||
'Route all outbound traffic through this gateway',
|
||||
),
|
||||
default: false,
|
||||
}),
|
||||
})
|
||||
|
||||
this.formDialog.open(FormComponent, {
|
||||
label: 'Add StartTunnel Gateway',
|
||||
label: 'Add Wireguard Gateway',
|
||||
data: {
|
||||
spec: await configBuilderToSpec(spec),
|
||||
buttons: [
|
||||
@@ -132,7 +146,8 @@ export default class GatewaysComponent {
|
||||
input.config.selection === 'paste'
|
||||
? input.config.value.file
|
||||
: await (input.config.value.file as any as File).text(),
|
||||
public: false,
|
||||
type: input.type as RR.GatewayType,
|
||||
setAsDefaultOutbound: input.setAsDefaultOutbound,
|
||||
})
|
||||
return true
|
||||
} catch (e: any) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
TuiButton,
|
||||
TuiDataList,
|
||||
TuiDropdown,
|
||||
TuiIcon,
|
||||
TuiOptGroup,
|
||||
TuiTextfield,
|
||||
} from '@taiga-ui/core'
|
||||
@@ -24,32 +25,55 @@ import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
import { GatewayPlus } from 'src/app/services/gateway.service'
|
||||
import { TuiBadge } from '@taiga-ui/kit'
|
||||
|
||||
@Component({
|
||||
selector: 'tr[gateway]',
|
||||
template: `
|
||||
@if (gateway(); as gateway) {
|
||||
<td class="name">
|
||||
<td>
|
||||
{{ gateway.name }}
|
||||
</td>
|
||||
<td class="type">
|
||||
@if (gateway.ipInfo.deviceType; as type) {
|
||||
{{ type }} ({{
|
||||
gateway.public ? ('public' | i18n) : ('private' | i18n)
|
||||
}})
|
||||
} @else {
|
||||
-
|
||||
@if (gateway.isDefaultOutbound) {
|
||||
<span tuiBadge tuiStatus appearance="positive">Default outbound</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@switch (gateway.ipInfo.deviceType) {
|
||||
@case ('ethernet') {
|
||||
<tui-icon icon="@tui.cable" />
|
||||
{{ 'Ethernet' | i18n }}
|
||||
}
|
||||
@case ('wireless') {
|
||||
<tui-icon icon="@tui.wifi" />
|
||||
{{ 'WiFi' | i18n }}
|
||||
}
|
||||
@case ('wireguard') {
|
||||
<tui-icon icon="@tui.shield" />
|
||||
{{ 'WireGuard' | i18n }}
|
||||
}
|
||||
@default {
|
||||
{{ gateway.ipInfo.deviceType }}
|
||||
}
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (gateway.type === 'outbound-only') {
|
||||
<tui-icon icon="@tui.arrow-up-right" />
|
||||
{{ 'Outbound Only' | i18n }}
|
||||
} @else {
|
||||
<tui-icon icon="@tui.arrow-left-right" />
|
||||
{{ 'Inbound/Outbound' | i18n }}
|
||||
}
|
||||
</td>
|
||||
<td class="lan">{{ gateway.lanIpv4.join(', ') }}</td>
|
||||
<td
|
||||
class="wan"
|
||||
[style.color]="
|
||||
gateway.ipInfo.wanIp ? 'var(--tui-text-warning)' : undefined
|
||||
gateway.ipInfo.wanIp ? undefined : 'var(--tui-text-warning)'
|
||||
"
|
||||
>
|
||||
{{ gateway.ipInfo.wanIp || ('Error' | i18n) }}
|
||||
</td>
|
||||
<td class="lan">{{ gateway.lanIpv4.join(', ') || '-' }}</td>
|
||||
<td>
|
||||
<button
|
||||
tuiIconButton
|
||||
@@ -67,6 +91,18 @@ import { GatewayPlus } from 'src/app/services/gateway.service'
|
||||
{{ 'Rename' | i18n }}
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
@if (!gateway.isDefaultOutbound) {
|
||||
<tui-opt-group>
|
||||
<button
|
||||
tuiOption
|
||||
new
|
||||
iconStart="@tui.arrow-up-right"
|
||||
(click)="setDefaultOutbound()"
|
||||
>
|
||||
{{ 'Set as Default Outbound' | i18n }}
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
}
|
||||
@if (gateway.ipInfo.deviceType === 'wireguard') {
|
||||
<tui-opt-group>
|
||||
<button
|
||||
@@ -87,19 +123,11 @@ import { GatewayPlus } from 'src/app/services/gateway.service'
|
||||
`,
|
||||
styles: `
|
||||
td:last-child {
|
||||
grid-area: 1 / 3 / 5;
|
||||
grid-area: 1 / 3 / 7;
|
||||
align-self: center;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.name {
|
||||
width: 14rem;
|
||||
}
|
||||
|
||||
.type {
|
||||
width: 14rem;
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
grid-template-columns: min-content 1fr min-content;
|
||||
|
||||
@@ -107,11 +135,15 @@ import { GatewayPlus } from 'src/app/services/gateway.service'
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.type {
|
||||
.connection {
|
||||
grid-column: span 2;
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.type {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.lan,
|
||||
.wan {
|
||||
grid-column: span 2;
|
||||
@@ -132,9 +164,11 @@ import { GatewayPlus } from 'src/app/services/gateway.service'
|
||||
TuiButton,
|
||||
TuiDropdown,
|
||||
TuiDataList,
|
||||
TuiIcon,
|
||||
TuiOptGroup,
|
||||
TuiTextfield,
|
||||
i18nPipe,
|
||||
TuiBadge,
|
||||
],
|
||||
})
|
||||
export class GatewaysItemComponent {
|
||||
@@ -166,6 +200,18 @@ export class GatewaysItemComponent {
|
||||
})
|
||||
}
|
||||
|
||||
async setDefaultOutbound() {
|
||||
const loader = this.loader.open().subscribe()
|
||||
|
||||
try {
|
||||
await this.api.setDefaultOutbound({ gateway: this.gateway().id })
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
async rename() {
|
||||
const { id, name } = this.gateway()
|
||||
const renameSpec = ISB.InputSpec.of({
|
||||
|
||||
@@ -8,12 +8,21 @@ import { GatewayService } from 'src/app/services/gateway.service'
|
||||
@Component({
|
||||
selector: 'gateways-table',
|
||||
template: `
|
||||
<table [appTable]="['Name', 'Type', $any('LAN IP'), $any('WAN IP'), null]">
|
||||
<table
|
||||
[appTable]="[
|
||||
'Name',
|
||||
'Connection',
|
||||
'Type',
|
||||
$any('WAN IP'),
|
||||
$any('LAN IP'),
|
||||
null,
|
||||
]"
|
||||
>
|
||||
@for (gateway of gatewayService.gateways(); track $index) {
|
||||
<tr [gateway]="gateway"></tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
<td colspan="7">
|
||||
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -160,7 +160,11 @@ export default class SystemSSHComponent {
|
||||
const loader = this.loader.open('Deleting').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.deleteSshKey({ fingerprint: '' })
|
||||
await Promise.all(
|
||||
fingerprints.map(fingerprint =>
|
||||
this.api.deleteSshKey({ fingerprint }),
|
||||
),
|
||||
)
|
||||
this.local$.next(
|
||||
all.filter(s => !fingerprints.includes(s.fingerprint)),
|
||||
)
|
||||
|
||||
@@ -5,8 +5,23 @@ import {
|
||||
inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared'
|
||||
import { TuiButton, TuiIcon, TuiTitle } from '@taiga-ui/core'
|
||||
import { NgTemplateOutlet } from '@angular/common'
|
||||
import {
|
||||
DialogService,
|
||||
ErrorService,
|
||||
i18nPipe,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
import { filter } from 'rxjs'
|
||||
import { IST } from '@start9labs/start-sdk'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiDataList,
|
||||
TuiDropdown,
|
||||
TuiIcon,
|
||||
TuiTextfield,
|
||||
TuiTitle,
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiBadge, TuiFade } from '@taiga-ui/kit'
|
||||
import { TuiCell } from '@taiga-ui/layout'
|
||||
import {
|
||||
@@ -22,50 +37,78 @@ import { wifiSpec } from './wifi.const'
|
||||
@Component({
|
||||
selector: '[wifi]',
|
||||
template: `
|
||||
@for (network of wifi; track $index) {
|
||||
@if (network.ssid) {
|
||||
<ng-template #row let-network>
|
||||
@if (getSignal(network.strength); as signal) {
|
||||
<tui-icon
|
||||
background="@tui.wifi"
|
||||
[icon]="signal.icon"
|
||||
[style.background]="'var(--tui-background-neutral-2)'"
|
||||
[style.color]="signal.color"
|
||||
/>
|
||||
} @else {
|
||||
<tui-icon icon="@tui.wifi-off" />
|
||||
}
|
||||
<tui-icon
|
||||
[icon]="network.security.length ? '@tui.lock' : '@tui.lock-open'"
|
||||
/>
|
||||
<div tuiTitle>
|
||||
<strong tuiFade>
|
||||
{{ network.ssid }}
|
||||
</strong>
|
||||
</div>
|
||||
@if (network.connected) {
|
||||
<tui-badge appearance="positive">
|
||||
{{ 'Connected' | i18n }}
|
||||
</tui-badge>
|
||||
}
|
||||
@if (network.connected === false) {
|
||||
<button
|
||||
tuiCell
|
||||
[disabled]="network.connected"
|
||||
(click)="prompt(network)"
|
||||
tuiIconButton
|
||||
tuiDropdown
|
||||
size="s"
|
||||
appearance="flat-grayscale"
|
||||
iconStart="@tui.ellipsis-vertical"
|
||||
[(tuiDropdownOpen)]="open"
|
||||
>
|
||||
<div tuiTitle>
|
||||
<strong tuiFade>
|
||||
{{ network.ssid }}
|
||||
@if (network.connected) {
|
||||
<tui-badge appearance="positive">
|
||||
{{ 'Connected' | i18n }}
|
||||
</tui-badge>
|
||||
}
|
||||
</strong>
|
||||
</div>
|
||||
@if (network.connected !== undefined) {
|
||||
{{ 'More' | i18n }}
|
||||
<tui-data-list *tuiTextfieldDropdown>
|
||||
<button
|
||||
tuiIconButton
|
||||
size="s"
|
||||
appearance="icon"
|
||||
iconStart="@tui.trash-2"
|
||||
(click.stop)="forget(network)"
|
||||
tuiOption
|
||||
new
|
||||
iconStart="@tui.wifi"
|
||||
(click)="prompt(network)"
|
||||
>
|
||||
{{ 'Connect' | i18n }}
|
||||
</button>
|
||||
<button
|
||||
tuiOption
|
||||
new
|
||||
iconStart="@tui.trash"
|
||||
class="g-negative"
|
||||
(click)="forget(network)"
|
||||
>
|
||||
{{ 'Forget' | i18n }}
|
||||
</button>
|
||||
} @else {
|
||||
<tui-icon
|
||||
[icon]="network.security.length ? '@tui.lock' : '@tui.lock-open'"
|
||||
/>
|
||||
}
|
||||
@if (getSignal(network.strength); as signal) {
|
||||
<tui-icon
|
||||
background="@tui.wifi"
|
||||
[icon]="signal.icon"
|
||||
[style.background]="'var(--tui-background-neutral-2)'"
|
||||
[style.color]="signal.color"
|
||||
/>
|
||||
} @else {
|
||||
<tui-icon icon="@tui.wifi-off" />
|
||||
}
|
||||
</tui-data-list>
|
||||
</button>
|
||||
}
|
||||
</ng-template>
|
||||
@for (network of wifi; track $index) {
|
||||
@if (network.ssid) {
|
||||
@if (network.connected === undefined) {
|
||||
<button tuiCell (click)="prompt(network)">
|
||||
<ng-container
|
||||
*ngTemplateOutlet="row; context: { $implicit: network }"
|
||||
/>
|
||||
</button>
|
||||
} @else {
|
||||
<div tuiCell>
|
||||
<ng-container
|
||||
*ngTemplateOutlet="row; context: { $implicit: network }"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
@@ -75,8 +118,6 @@ import { wifiSpec } from './wifi.const'
|
||||
}
|
||||
|
||||
[tuiCell] {
|
||||
padding-inline: 1rem !important;
|
||||
|
||||
&:disabled > * {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -88,11 +129,24 @@ import { wifiSpec } from './wifi.const'
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TuiCell, TuiTitle, TuiBadge, TuiButton, TuiIcon, TuiFade, i18nPipe],
|
||||
imports: [
|
||||
NgTemplateOutlet,
|
||||
TuiCell,
|
||||
TuiTitle,
|
||||
TuiBadge,
|
||||
TuiButton,
|
||||
TuiIcon,
|
||||
TuiFade,
|
||||
TuiDropdown,
|
||||
TuiDataList,
|
||||
TuiTextfield,
|
||||
i18nPipe,
|
||||
],
|
||||
})
|
||||
export class WifiTableComponent {
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly dialogs = inject(DialogService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly formDialog = inject(FormDialogService)
|
||||
private readonly component = inject(SystemWifiComponent)
|
||||
@@ -102,6 +156,8 @@ export class WifiTableComponent {
|
||||
@Input()
|
||||
wifi: readonly Wifi[] = []
|
||||
|
||||
open = false
|
||||
|
||||
getSignal(signal: number) {
|
||||
if (signal < 5) {
|
||||
return null
|
||||
@@ -141,17 +197,30 @@ export class WifiTableComponent {
|
||||
|
||||
async prompt(network: Wifi): Promise<void> {
|
||||
if (!network.security.length) {
|
||||
await this.component.saveAndConnect(network.ssid)
|
||||
this.dialogs
|
||||
.openConfirm({
|
||||
label: `${this.i18n.transform('Connect to')} ${network.ssid}?`,
|
||||
size: 's',
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => this.component.saveAndConnect(network.ssid))
|
||||
} else {
|
||||
const ssid = wifiSpec.spec['ssid'] as IST.ValueSpecText
|
||||
const spec: IST.InputSpec = {
|
||||
...wifiSpec.spec,
|
||||
ssid: { ...ssid, disabled: 'ssid', default: network.ssid },
|
||||
}
|
||||
|
||||
this.formDialog.open<FormContext<WiFiForm>>(FormComponent, {
|
||||
label: 'Password needed',
|
||||
data: {
|
||||
spec: wifiSpec.spec,
|
||||
spec,
|
||||
value: { ssid: network.ssid, password: '' },
|
||||
buttons: [
|
||||
{
|
||||
text: this.i18n.transform('Connect')!,
|
||||
handler: async ({ ssid, password }) =>
|
||||
this.component.saveAndConnect(ssid, password),
|
||||
handler: async ({ password }) =>
|
||||
this.component.saveAndConnect(network.ssid, password),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import { toSignal } from '@angular/core/rxjs-interop'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import {
|
||||
DocsLinkDirective,
|
||||
ErrorService,
|
||||
i18nKey,
|
||||
i18nPipe,
|
||||
@@ -19,11 +20,9 @@ import {
|
||||
TuiAppearance,
|
||||
TuiButton,
|
||||
TuiLoader,
|
||||
TuiNotification,
|
||||
TuiTitle,
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiSwitch } from '@taiga-ui/kit'
|
||||
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
|
||||
import { TuiCardLarge } from '@taiga-ui/layout'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { catchError, defer, map, merge, Observable, of, Subject } from 'rxjs'
|
||||
import {
|
||||
@@ -47,23 +46,20 @@ import { wifiSpec } from './wifi.const'
|
||||
</a>
|
||||
WiFi
|
||||
</ng-container>
|
||||
<header tuiHeader>
|
||||
<tui-notification appearance="negative">
|
||||
<div tuiTitle>
|
||||
{{ 'Deprecated' | i18n }}
|
||||
<div tuiSubtitle>
|
||||
{{
|
||||
'WiFi support will be removed in StartOS v0.4.1. If you do not have access to Ethernet, you can use a WiFi extender to connect to the local network, then connect your server to the extender via Ethernet. Please contact Start9 support with any questions or concerns.'
|
||||
| i18n
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</tui-notification>
|
||||
</header>
|
||||
@if (status()?.interface) {
|
||||
<section class="g-card">
|
||||
<header>
|
||||
Wi-Fi
|
||||
<a
|
||||
tuiIconButton
|
||||
size="xs"
|
||||
docsLink
|
||||
path="/user-manual/wifi.html"
|
||||
appearance="icon"
|
||||
iconStart="@tui.external-link"
|
||||
>
|
||||
{{ 'Documentation' | i18n }}
|
||||
</a>
|
||||
<input
|
||||
type="checkbox"
|
||||
tuiSwitch
|
||||
@@ -92,8 +88,8 @@ import { wifiSpec } from './wifi.const'
|
||||
></div>
|
||||
}
|
||||
<p>
|
||||
<button tuiButton (click)="other(data)">
|
||||
{{ 'Add' | i18n }}
|
||||
<button tuiButton (click)="other(data)" appearance="flat">
|
||||
+ {{ 'Connect to hidden network' | i18n }}
|
||||
</button>
|
||||
</p>
|
||||
} @else {
|
||||
@@ -128,10 +124,8 @@ import { wifiSpec } from './wifi.const'
|
||||
TitleDirective,
|
||||
RouterLink,
|
||||
PlaceholderComponent,
|
||||
TuiHeader,
|
||||
TuiTitle,
|
||||
TuiNotification,
|
||||
i18nPipe,
|
||||
DocsLinkDirective,
|
||||
],
|
||||
})
|
||||
export default class SystemWifiComponent {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { AsyncPipe } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { toSignal } from '@angular/core/rxjs-interop'
|
||||
import { RouterModule } from '@angular/router'
|
||||
@@ -6,12 +5,9 @@ import { i18nPipe } from '@start9labs/shared'
|
||||
import { TuiIcon, TuiTitle } from '@taiga-ui/core'
|
||||
import { TuiBadgeNotification } from '@taiga-ui/kit'
|
||||
import { TuiCell } from '@taiga-ui/layout'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { BadgeService } from 'src/app/services/badge.service'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { TitleDirective } from 'src/app/services/title.service'
|
||||
import { SYSTEM_MENU } from './system.const'
|
||||
import { map } from 'rxjs'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
@@ -26,9 +22,6 @@ import { map } from 'rxjs'
|
||||
tuiCell="s"
|
||||
routerLinkActive="active"
|
||||
[routerLink]="page.link"
|
||||
[style.display]="
|
||||
!(wifiEnabled$ | async) && page.item === 'WiFi' ? 'none' : null
|
||||
"
|
||||
>
|
||||
<tui-icon [icon]="page.icon" />
|
||||
<span tuiTitle>
|
||||
@@ -116,13 +109,9 @@ import { map } from 'rxjs'
|
||||
TitleDirective,
|
||||
TuiBadgeNotification,
|
||||
i18nPipe,
|
||||
AsyncPipe,
|
||||
],
|
||||
})
|
||||
export class SystemComponent {
|
||||
readonly menu = SYSTEM_MENU
|
||||
readonly badge = toSignal(inject(BadgeService).getCount('system'))
|
||||
readonly wifiEnabled$ = inject<PatchDB<DataModel>>(PatchDB)
|
||||
.watch$('serverInfo', 'network', 'wifi')
|
||||
.pipe(map(wifi => !!wifi.interface && wifi.enabled))
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export const SYSTEM_MENU = [
|
||||
},
|
||||
{
|
||||
icon: '@tui.mail',
|
||||
item: 'Email',
|
||||
item: 'SMTP',
|
||||
link: 'email',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2253,6 +2253,7 @@ export namespace Mock {
|
||||
},
|
||||
},
|
||||
storeExposedDependents: [],
|
||||
outboundGateway: null,
|
||||
registry: 'https://registry.start9.com/',
|
||||
developerKey: 'developer-key',
|
||||
tasks: {
|
||||
@@ -2321,6 +2322,7 @@ export namespace Mock {
|
||||
},
|
||||
hosts: {},
|
||||
storeExposedDependents: [],
|
||||
outboundGateway: null,
|
||||
registry: 'https://registry.start9.com/',
|
||||
developerKey: 'developer-key',
|
||||
tasks: {},
|
||||
@@ -2427,6 +2429,7 @@ export namespace Mock {
|
||||
},
|
||||
hosts: {},
|
||||
storeExposedDependents: [],
|
||||
outboundGateway: null,
|
||||
registry: 'https://registry.start9.com/',
|
||||
developerKey: 'developer-key',
|
||||
tasks: {
|
||||
|
||||
@@ -252,10 +252,13 @@ export namespace RR {
|
||||
|
||||
// network
|
||||
|
||||
export type GatewayType = 'inbound-outbound' | 'outbound-only'
|
||||
|
||||
export type AddTunnelReq = {
|
||||
name: string
|
||||
config: string // file contents
|
||||
public: boolean
|
||||
type: GatewayType
|
||||
setAsDefaultOutbound?: boolean
|
||||
} // net.tunnel.add
|
||||
export type AddTunnelRes = {
|
||||
id: string
|
||||
@@ -270,6 +273,17 @@ export namespace RR {
|
||||
export type RemoveTunnelReq = { id: string } // net.tunnel.remove
|
||||
export type RemoveTunnelRes = null
|
||||
|
||||
// Set default outbound gateway
|
||||
export type SetDefaultOutboundReq = { gateway: string | null } // net.gateway.set-default-outbound
|
||||
export type SetDefaultOutboundRes = null
|
||||
|
||||
// Set service outbound gateway
|
||||
export type SetServiceOutboundReq = {
|
||||
packageId: string
|
||||
gateway: string | null
|
||||
} // package.set-outbound-gateway
|
||||
export type SetServiceOutboundRes = null
|
||||
|
||||
export type InitAcmeReq = {
|
||||
provider: string
|
||||
contact: string[]
|
||||
|
||||
@@ -175,6 +175,14 @@ export abstract class ApiService {
|
||||
|
||||
abstract removeTunnel(params: RR.RemoveTunnelReq): Promise<RR.RemoveTunnelRes>
|
||||
|
||||
abstract setDefaultOutbound(
|
||||
params: RR.SetDefaultOutboundReq,
|
||||
): Promise<RR.SetDefaultOutboundRes>
|
||||
|
||||
abstract setServiceOutbound(
|
||||
params: RR.SetServiceOutboundReq,
|
||||
): Promise<RR.SetServiceOutboundRes>
|
||||
|
||||
// ** domains **
|
||||
|
||||
// wifi
|
||||
|
||||
@@ -355,6 +355,18 @@ export class LiveApiService extends ApiService {
|
||||
return this.rpcRequest({ method: 'net.tunnel.remove', params })
|
||||
}
|
||||
|
||||
async setDefaultOutbound(
|
||||
params: RR.SetDefaultOutboundReq,
|
||||
): Promise<RR.SetDefaultOutboundRes> {
|
||||
return this.rpcRequest({ method: 'net.gateway.set-default-outbound', params })
|
||||
}
|
||||
|
||||
async setServiceOutbound(
|
||||
params: RR.SetServiceOutboundReq,
|
||||
): Promise<RR.SetServiceOutboundRes> {
|
||||
return this.rpcRequest({ method: 'package.set-outbound-gateway', params })
|
||||
}
|
||||
|
||||
// wifi
|
||||
|
||||
async enableWifi(params: RR.EnabledWifiReq): Promise<RR.EnabledWifiRes> {
|
||||
|
||||
@@ -566,13 +566,12 @@ export class MockApiService extends ApiService {
|
||||
|
||||
const id = `wg${this.proxyId++}`
|
||||
|
||||
const patch: AddOperation<T.NetworkInterfaceInfo>[] = [
|
||||
const patch: AddOperation<any>[] = [
|
||||
{
|
||||
op: PatchOp.ADD,
|
||||
path: `/serverInfo/network/gateways/${id}`,
|
||||
value: {
|
||||
name: params.name,
|
||||
public: params.public,
|
||||
secure: false,
|
||||
ipInfo: {
|
||||
name: id,
|
||||
@@ -584,9 +583,19 @@ export class MockApiService extends ApiService {
|
||||
lanIp: ['192.168.1.10'],
|
||||
dnsServers: [],
|
||||
},
|
||||
type: params.type,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
if (params.setAsDefaultOutbound) {
|
||||
;(patch as any[]).push({
|
||||
op: PatchOp.REPLACE,
|
||||
path: '/serverInfo/network/defaultOutbound',
|
||||
value: id,
|
||||
})
|
||||
}
|
||||
|
||||
this.mockRevision(patch)
|
||||
|
||||
return { id }
|
||||
@@ -620,6 +629,38 @@ export class MockApiService extends ApiService {
|
||||
return null
|
||||
}
|
||||
|
||||
async setDefaultOutbound(
|
||||
params: RR.SetDefaultOutboundReq,
|
||||
): Promise<RR.SetDefaultOutboundRes> {
|
||||
await pauseFor(2000)
|
||||
const patch = [
|
||||
{
|
||||
op: PatchOp.REPLACE,
|
||||
path: '/serverInfo/network/defaultOutbound',
|
||||
value: params.gateway,
|
||||
},
|
||||
]
|
||||
this.mockRevision(patch)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async setServiceOutbound(
|
||||
params: RR.SetServiceOutboundReq,
|
||||
): Promise<RR.SetServiceOutboundRes> {
|
||||
await pauseFor(2000)
|
||||
const patch = [
|
||||
{
|
||||
op: PatchOp.REPLACE,
|
||||
path: `/packageData/${params.packageId}/outboundGateway`,
|
||||
value: params.gateway,
|
||||
},
|
||||
]
|
||||
this.mockRevision(patch)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// wifi
|
||||
|
||||
async enableWifi(params: RR.EnabledWifiReq): Promise<RR.EnabledWifiRes> {
|
||||
|
||||
@@ -124,8 +124,8 @@ export const mockPatchData: DataModel = {
|
||||
gateways: {
|
||||
eth0: {
|
||||
name: null,
|
||||
public: null,
|
||||
secure: null,
|
||||
type: null,
|
||||
ipInfo: {
|
||||
name: 'Wired Connection 1',
|
||||
scopeId: 1,
|
||||
@@ -139,8 +139,8 @@ export const mockPatchData: DataModel = {
|
||||
},
|
||||
wlan0: {
|
||||
name: null,
|
||||
public: null,
|
||||
secure: null,
|
||||
type: null,
|
||||
ipInfo: {
|
||||
name: 'Wireless Connection 1',
|
||||
scopeId: 2,
|
||||
@@ -157,8 +157,8 @@ export const mockPatchData: DataModel = {
|
||||
},
|
||||
wireguard1: {
|
||||
name: 'StartTunnel',
|
||||
public: null,
|
||||
secure: null,
|
||||
type: 'inbound-outbound',
|
||||
ipInfo: {
|
||||
name: 'wireguard1',
|
||||
scopeId: 2,
|
||||
@@ -173,7 +173,23 @@ export const mockPatchData: DataModel = {
|
||||
dnsServers: ['1.1.1.1'],
|
||||
},
|
||||
},
|
||||
wireguard2: {
|
||||
name: 'Mullvad VPN',
|
||||
secure: null,
|
||||
type: 'outbound-only',
|
||||
ipInfo: {
|
||||
name: 'wireguard2',
|
||||
scopeId: 4,
|
||||
deviceType: 'wireguard',
|
||||
subnets: [],
|
||||
wanIp: '198.51.100.77',
|
||||
ntpServers: [],
|
||||
lanIp: [],
|
||||
dnsServers: ['10.64.0.1'],
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultOutbound: 'eth0',
|
||||
dns: {
|
||||
dhcpServers: ['1.1.1.1', '8.8.8.8'],
|
||||
staticServers: null,
|
||||
@@ -320,6 +336,7 @@ export const mockPatchData: DataModel = {
|
||||
},
|
||||
hosts: {},
|
||||
storeExposedDependents: [],
|
||||
outboundGateway: null,
|
||||
registry: 'https://registry.start9.com/',
|
||||
developerKey: 'developer-key',
|
||||
tasks: {
|
||||
@@ -624,6 +641,7 @@ export const mockPatchData: DataModel = {
|
||||
},
|
||||
},
|
||||
storeExposedDependents: [],
|
||||
outboundGateway: null,
|
||||
registry: 'https://registry.start9.com/',
|
||||
developerKey: 'developer-key',
|
||||
tasks: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ErrorService,
|
||||
i18nKey,
|
||||
i18nPipe,
|
||||
i18nService,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
@@ -24,6 +25,7 @@ export class ControlsService {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
|
||||
private readonly i18n = inject(i18nPipe)
|
||||
private readonly i18nService = inject(i18nService)
|
||||
|
||||
async start({ title, alerts, id }: T.Manifest, unmet: boolean) {
|
||||
const deps =
|
||||
@@ -31,7 +33,7 @@ export class ControlsService {
|
||||
|
||||
if (
|
||||
(unmet && !(await this.alert(deps))) ||
|
||||
(alerts.start && !(await this.alert(alerts.start as i18nKey)))
|
||||
(alerts.start && !(await this.alert(alerts.start)))
|
||||
) {
|
||||
return
|
||||
}
|
||||
@@ -49,7 +51,7 @@ export class ControlsService {
|
||||
|
||||
async stop({ id, title, alerts }: T.Manifest) {
|
||||
const depMessage = `${this.i18n.transform('Services that depend on')} ${title} ${this.i18n.transform('will no longer work properly and may crash.')}`
|
||||
let content = alerts.stop || ''
|
||||
let content = alerts.stop ? this.i18nService.localize(alerts.stop) : ''
|
||||
|
||||
if (hasCurrentDeps(id, await getAllPackages(this.patch))) {
|
||||
content = content ? `${content}.\n\n${depMessage}` : depMessage
|
||||
@@ -113,14 +115,14 @@ export class ControlsService {
|
||||
})
|
||||
}
|
||||
|
||||
private alert(content: i18nKey): Promise<boolean> {
|
||||
private alert(content: T.LocaleString): Promise<boolean> {
|
||||
return firstValueFrom(
|
||||
this.dialog
|
||||
.openConfirm({
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content,
|
||||
content: this.i18nService.localize(content),
|
||||
yes: 'Continue',
|
||||
no: 'Cancel',
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { inject, Injectable } from '@angular/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { T, utils } from '@start9labs/start-sdk'
|
||||
import { map } from 'rxjs/operators'
|
||||
import { map } from 'rxjs'
|
||||
import { DataModel } from './patch-db/data-model'
|
||||
import { toSignal } from '@angular/core/rxjs-interop'
|
||||
|
||||
@@ -12,39 +12,47 @@ export type GatewayPlus = T.NetworkInterfaceInfo & {
|
||||
subnets: utils.IpNet[]
|
||||
lanIpv4: string[]
|
||||
wanIp?: utils.IpAddress
|
||||
public: boolean
|
||||
isDefaultOutbound: boolean
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GatewayService {
|
||||
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
|
||||
|
||||
private readonly network$ = this.patch.watch$('serverInfo', 'network')
|
||||
|
||||
readonly defaultOutbound = toSignal(
|
||||
this.network$.pipe(map(n => n.defaultOutbound)),
|
||||
)
|
||||
|
||||
readonly gateways = toSignal(
|
||||
inject<PatchDB<DataModel>>(PatchDB)
|
||||
.watch$('serverInfo', 'network', 'gateways')
|
||||
.pipe(
|
||||
map(gateways =>
|
||||
Object.entries(gateways)
|
||||
.filter(([_, val]) => !!val?.ipInfo)
|
||||
.filter(
|
||||
([_, val]) =>
|
||||
val?.ipInfo?.deviceType !== 'bridge' &&
|
||||
val?.ipInfo?.deviceType !== 'loopback',
|
||||
)
|
||||
.map(([id, val]) => {
|
||||
const subnets =
|
||||
val.ipInfo?.subnets.map(s => utils.IpNet.parse(s)) ?? []
|
||||
const name = val.name ?? val.ipInfo!.name
|
||||
return {
|
||||
...val,
|
||||
id,
|
||||
name,
|
||||
subnets,
|
||||
lanIpv4: subnets.filter(s => s.isIpv4()).map(s => s.address),
|
||||
public: val.public ?? subnets.some(s => s.isPublic()),
|
||||
wanIp:
|
||||
val.ipInfo?.wanIp && utils.IpAddress.parse(val.ipInfo?.wanIp),
|
||||
} as GatewayPlus
|
||||
}),
|
||||
),
|
||||
),
|
||||
this.network$.pipe(
|
||||
map(network => {
|
||||
const gateways = network.gateways
|
||||
const defaultOutbound = network.defaultOutbound
|
||||
return Object.entries(gateways)
|
||||
.filter(([_, val]) => !!val?.ipInfo)
|
||||
.filter(
|
||||
([_, val]) =>
|
||||
val?.ipInfo?.deviceType !== 'bridge' &&
|
||||
val?.ipInfo?.deviceType !== 'loopback',
|
||||
)
|
||||
.map(([id, val]) => {
|
||||
const subnets =
|
||||
val.ipInfo?.subnets.map(s => utils.IpNet.parse(s)) ?? []
|
||||
const name = val.name ?? val.ipInfo!.name
|
||||
return {
|
||||
...val,
|
||||
id,
|
||||
name,
|
||||
subnets,
|
||||
lanIpv4: subnets.filter(s => s.isIpv4()).map(s => s.address),
|
||||
wanIp:
|
||||
val.ipInfo?.wanIp && utils.IpAddress.parse(val.ipInfo?.wanIp),
|
||||
isDefaultOutbound: id === defaultOutbound,
|
||||
} as GatewayPlus
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function getInstalledBaseStatus(statusInfo: T.StatusInfo): BaseStatus {
|
||||
(!statusInfo.started ||
|
||||
Object.values(statusInfo.health)
|
||||
.filter(h => !!h)
|
||||
.some(h => h.result === 'starting'))
|
||||
.some(h => h.result === 'starting' || h.result === 'waiting'))
|
||||
) {
|
||||
return 'starting'
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ErrorService,
|
||||
i18nKey,
|
||||
i18nPipe,
|
||||
i18nService,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
@@ -27,6 +28,7 @@ export class StandardActionsService {
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly router = inject(Router)
|
||||
private readonly i18n = inject(i18nPipe)
|
||||
private readonly i18nService = inject(i18nService)
|
||||
|
||||
async rebuild(id: string) {
|
||||
const loader = this.loader.open('Rebuilding container').subscribe()
|
||||
@@ -50,11 +52,12 @@ export class StandardActionsService {
|
||||
): Promise<void> {
|
||||
let content = soft
|
||||
? ''
|
||||
: alerts.uninstall ||
|
||||
`${this.i18n.transform('Uninstalling')} ${title} ${this.i18n.transform('will permanently delete its data.')}`
|
||||
: alerts.uninstall
|
||||
? this.i18nService.localize(alerts.uninstall)
|
||||
: `${this.i18n.transform('Uninstalling')} ${title} ${this.i18n.transform('will permanently delete its data.')}`
|
||||
|
||||
if (hasCurrentDeps(id, await getAllPackages(this.patch))) {
|
||||
content = `${content}${content ? ' ' : ''}${this.i18n.transform('Services that depend on')} ${title} ${this.i18n.transform('will no longer work properly and may crash.')}`
|
||||
content = `${content ? `${content} ` : ''}${this.i18n.transform('Services that depend on')} ${title} ${this.i18n.transform('will no longer work properly and may crash.')}`
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
|
||||
Reference in New Issue
Block a user